@saadnvd1/agent-os 0.3.471 → 0.3.473

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +100 -0
  2. package/app/api/models/route.ts +34 -0
  3. package/app/api/models/test/route.ts +17 -0
  4. package/app/api/sessions/[id]/summarize/route.ts +4 -1
  5. package/app/api/tasks/[id]/model/route.ts +23 -0
  6. package/app/api/tasks/route.ts +5 -1
  7. package/app/api/usage/compare/route.ts +21 -0
  8. package/app/api/usage/route.ts +7 -4
  9. package/app/api/workspaces/[id]/models/route.ts +27 -0
  10. package/components/CommandPalette/useAppCommands.ts +19 -1
  11. package/components/Draft/DraftChips.tsx +28 -0
  12. package/components/Draft/DraftPanel.tsx +1 -1
  13. package/components/Models/ModelSelect.tsx +67 -0
  14. package/components/Models/ModelsSection.tsx +199 -0
  15. package/components/Models/ProviderCard.tsx +271 -0
  16. package/components/SessionList/SidebarHeader.tsx +5 -1
  17. package/components/Settings/SettingsDialog.test.ts +3 -0
  18. package/components/Settings/SettingsDialog.tsx +9 -0
  19. package/components/Settings/commands.ts +19 -0
  20. package/components/Settings/entry-points.test.ts +1 -0
  21. package/components/Tasks/ResumeOnModel.tsx +72 -0
  22. package/components/Tasks/TaskRow.tsx +4 -0
  23. package/components/Usage/CompareView.tsx +211 -0
  24. package/components/Usage/ModelSpend.tsx +133 -0
  25. package/components/Usage/UsageDialog.tsx +38 -3
  26. package/components/Usage/format.ts +9 -0
  27. package/components/Workspaces/WorkspaceModelsForm.tsx +71 -0
  28. package/components/Workspaces/WorkspaceSettings.tsx +2 -0
  29. package/data/models/index.ts +125 -0
  30. package/data/usage/index.ts +3 -0
  31. package/lib/agents/launch.ts +11 -2
  32. package/lib/chat/context.ts +1 -1
  33. package/lib/chat/runner.ts +5 -0
  34. package/lib/chat/worker/extras.ts +12 -0
  35. package/lib/chat/worker/host.ts +13 -2
  36. package/lib/chat/worker/main.ts +5 -2
  37. package/lib/db/migrations.ts +28 -0
  38. package/lib/db/types.ts +6 -0
  39. package/lib/drafts.ts +3 -0
  40. package/lib/models/config.ts +218 -0
  41. package/lib/models/launch.ts +93 -0
  42. package/lib/models/plan-step.ts +94 -0
  43. package/lib/models/providers.ts +163 -0
  44. package/lib/models/resolve.ts +246 -0
  45. package/lib/models/resume.ts +99 -0
  46. package/lib/models/roles.ts +98 -0
  47. package/lib/models/secrets.ts +86 -0
  48. package/lib/models/session-model.ts +40 -0
  49. package/lib/models/test-provider.ts +116 -0
  50. package/lib/orchestrator/act.ts +13 -1
  51. package/lib/orchestrator/brief.ts +2 -1
  52. package/lib/orchestrator/claude-cli.ts +44 -15
  53. package/lib/orchestrator/home.ts +2 -2
  54. package/lib/orchestrator/review.ts +2 -0
  55. package/lib/orchestrator/scope.ts +2 -0
  56. package/lib/orchestrator/serve.ts +13 -0
  57. package/lib/orchestrator/tool-names.ts +1 -0
  58. package/lib/orchestrator/tool-schemas.ts +43 -0
  59. package/lib/orchestrator/tools.ts +3 -1
  60. package/lib/pr-generation.ts +5 -2
  61. package/lib/providers/registry.ts +1 -1
  62. package/lib/session-titles.ts +1 -1
  63. package/lib/sessions/launch.ts +19 -4
  64. package/lib/settings-url.ts +1 -0
  65. package/lib/tasks/index.ts +59 -8
  66. package/lib/tasks/queue.ts +14 -5
  67. package/lib/tasks/start.ts +7 -2
  68. package/lib/usage/by-model.ts +105 -0
  69. package/lib/usage/check-runs.ts +5 -2
  70. package/lib/usage/compare.ts +280 -0
  71. package/lib/usage/cost.ts +71 -0
  72. package/lib/usage/report.ts +26 -0
  73. package/lib/usage/turns.ts +79 -18
  74. package/package.json +1 -1
  75. package/stores/usageUi.ts +11 -2
@@ -4,6 +4,14 @@
4
4
  * drops it.
5
5
  */
6
6
 
7
+ import {
8
+ parseOverride,
9
+ resolveRole,
10
+ sessionModelRef,
11
+ type ModelOverride,
12
+ } from "../models/resolve";
13
+ import { effectiveModels } from "../models/config";
14
+ import { ANTHROPIC } from "../models/roles";
7
15
  import { processTable, runsSomething } from "../process-table";
8
16
  import { randomUUID } from "crypto";
9
17
  import { db, queries, type Session } from "../db";
@@ -19,7 +27,6 @@ import {
19
27
  isBranchName,
20
28
  slugify,
21
29
  } from "../git";
22
- import { resolveModelForAgent } from "../model-catalog";
23
30
  import { getProvider } from "../providers";
24
31
  import { statusDetector } from "../status-detector";
25
32
  import { buildTaskBrief, type StackedOn } from "./brief";
@@ -85,6 +92,10 @@ export interface TaskView {
85
92
  hostError: string | null;
86
93
  // Merges by itself once every gate passes (orchestrator/armed.ts).
87
94
  armed?: boolean;
95
+ // What it runs on now, "provider/model", and whether it's still in its
96
+ // plan step (lib/models).
97
+ model?: string;
98
+ planning?: boolean;
88
99
  }
89
100
 
90
101
  export async function createTask(opts: {
@@ -92,7 +103,12 @@ export async function createTask(opts: {
92
103
  prompt: string;
93
104
  // Its name; generated from the prompt when absent.
94
105
  name?: string;
95
- model?: string;
106
+ // Its own model: "provider/model" (or an Anthropic alias) for all its
107
+ // roles, or a map of role to one (lib/models). Its workspace's and
108
+ // Settings' role models otherwise.
109
+ model?: ModelOverride;
110
+ // Plan first on the planning model (Settings' default otherwise).
111
+ plan?: boolean;
96
112
  // Started from this LumifyHub card, which the task then moves.
97
113
  cardId?: string;
98
114
  // Stacked: cut from another task's pushed branch, at this exact commit.
@@ -169,7 +185,7 @@ async function startTask(
169
185
  id,
170
186
  prompt,
171
187
  name: opts.name,
172
- model: opts.model,
188
+ model: typeof opts.model === "string" ? opts.model : undefined,
173
189
  baseBranch: opts.baseBranch,
174
190
  view,
175
191
  });
@@ -179,6 +195,12 @@ async function startTask(
179
195
 
180
196
  if (opts.baseBranch !== undefined && !isBranchName(opts.baseBranch))
181
197
  throw new Error(`"${opts.baseBranch}" isn't a branch name`);
198
+ const routing = taskRouting(
199
+ project.workspace_id,
200
+ view,
201
+ opts.model,
202
+ opts.plan
203
+ );
182
204
  const projectPath = expandHome(project.working_directory);
183
205
  const naming = await nameFor(prompt, projectPath, opts.name);
184
206
  const feature = `${slugify(naming.name.split(/\s+/).slice(0, 6).join(" "))}-${id.slice(0, 4)}`;
@@ -198,10 +220,7 @@ async function startTask(
198
220
  });
199
221
 
200
222
  const provider = getProvider("claude");
201
- const model = resolveModelForAgent(
202
- "claude",
203
- opts.model || project.default_model
204
- );
223
+ const model = routing.first.model;
205
224
  const tmuxName = `${provider.id}-${id}`;
206
225
 
207
226
  queries
@@ -225,7 +244,8 @@ async function startTask(
225
244
  .run(wt.worktreePath, wt.branchName, baseBranch, null, id);
226
245
  // Everything the launch needs is on the row, so a restart can resume it.
227
246
  db.prepare(
228
- `UPDATE sessions SET task_prompt = ?, task_brief = ?, task_status = 'running', setup_status = 'running', name_source = ?, view = ? WHERE id = ?`
247
+ `UPDATE sessions SET task_prompt = ?, task_brief = ?, task_status = 'running', setup_status = 'running', name_source = ?, view = ?,
248
+ model_provider = ?, model_overrides = ?, plan_phase = ? WHERE id = ?`
229
249
  ).run(
230
250
  prompt,
231
251
  buildTaskBrief({
@@ -235,6 +255,9 @@ async function startTask(
235
255
  }),
236
256
  naming.source,
237
257
  view,
258
+ routing.first.provider.id === ANTHROPIC ? null : routing.first.provider.id,
259
+ routing.override ? JSON.stringify(routing.override) : null,
260
+ routing.plan ? "planning" : null,
238
261
  id
239
262
  );
240
263
  opts.onCreated?.(id);
@@ -340,6 +363,8 @@ export async function taskView(session: Session): Promise<TaskView> {
340
363
  hostName: null,
341
364
  hostError: null,
342
365
  armed: !!session.merge_armed_at,
366
+ model: sessionModelRef(session),
367
+ planning: session.plan_phase === "planning",
343
368
  };
344
369
  }
345
370
 
@@ -365,3 +390,29 @@ export async function listTasks(): Promise<TaskView[]> {
365
390
  b.createdAt.localeCompare(a.createdAt)
366
391
  );
367
392
  }
393
+
394
+ // The model a task starts on, and whether it plans first: only as a chat,
395
+ // where the handoff can follow the end of its plan turn, and only when
396
+ // planning and implementing are on different models. Both roles resolve
397
+ // now, so a bad override or a provider that's off fails the start.
398
+ export function taskRouting(
399
+ workspaceId: string | null,
400
+ view: StartView,
401
+ model: unknown,
402
+ plan?: boolean
403
+ ) {
404
+ const override = parseOverride(model);
405
+ const models = effectiveModels();
406
+ const opts = { workspaceId, override };
407
+ const implement = resolveRole("implement", opts, models);
408
+ const planning = resolveRole("plan", opts, models);
409
+ const planStep =
410
+ view === "chat" &&
411
+ (plan ?? models.planStep) &&
412
+ planning.ref !== implement.ref;
413
+ return {
414
+ override,
415
+ plan: planStep,
416
+ first: planStep ? planning : implement,
417
+ };
418
+ }
@@ -11,6 +11,7 @@
11
11
  * is deleted: a started or removed row stays as history.
12
12
  */
13
13
 
14
+ import type { ModelOverride } from "../models/resolve";
14
15
  import { randomUUID } from "crypto";
15
16
  import { db, type Session } from "../db";
16
17
  import { getProject } from "../projects";
@@ -41,6 +42,8 @@ export interface QueueRow {
41
42
  prompt: string;
42
43
  name: string | null;
43
44
  model: string | null;
45
+ // A per-role model map (lib/models), when that's what it was given.
46
+ model_overrides: string | null;
44
47
  view: "chat" | "terminal" | null;
45
48
  base_branch: string | null;
46
49
  host_id: string | null;
@@ -200,7 +203,7 @@ export interface QueueRequest {
200
203
  projectId: string;
201
204
  prompt: string;
202
205
  name?: string;
203
- model?: string;
206
+ model?: ModelOverride;
204
207
  view?: "chat" | "terminal";
205
208
  baseBranch?: string;
206
209
  hostId?: string;
@@ -247,9 +250,9 @@ export function queueIfNeeded(req: QueueRequest): QueuedTaskView | null {
247
250
  if (!req.prompt.trim()) throw new Error("Describe the task");
248
251
  const id = randomUUID();
249
252
  db.prepare(
250
- `INSERT INTO task_queue (id, project_id, prompt, name, model, view, base_branch,
253
+ `INSERT INTO task_queue (id, project_id, prompt, name, model, model_overrides, view, base_branch,
251
254
  host_id, after_task, after_any, origin_workspace_id, position)
252
- VALUES (@id, @project_id, @prompt, @name, @model, @view, @base_branch,
255
+ VALUES (@id, @project_id, @prompt, @name, @model, @model_overrides, @view, @base_branch,
253
256
  @host_id, @after_task, @after_any, @origin,
254
257
  (SELECT COALESCE(MAX(position), 0) + 1 FROM task_queue))`
255
258
  ).run({
@@ -257,7 +260,11 @@ export function queueIfNeeded(req: QueueRequest): QueuedTaskView | null {
257
260
  project_id: project.id,
258
261
  prompt: req.prompt.trim(),
259
262
  name: req.name?.trim() || null,
260
- model: req.model || null,
263
+ model: typeof req.model === "string" ? req.model || null : null,
264
+ model_overrides:
265
+ req.model && typeof req.model === "object"
266
+ ? JSON.stringify(req.model)
267
+ : null,
261
268
  view: req.view ?? null,
262
269
  base_branch: req.baseBranch || null,
263
270
  host_id: req.hostId || null,
@@ -429,7 +436,9 @@ async function startRow(
429
436
  projectId: r.project_id,
430
437
  prompt: r.prompt,
431
438
  name: r.name ?? undefined,
432
- model: r.model ?? undefined,
439
+ model: r.model_overrides
440
+ ? (JSON.parse(r.model_overrides) as ModelOverride)
441
+ : (r.model ?? undefined),
433
442
  view: r.view ?? undefined,
434
443
  baseBranch: r.base_branch ?? undefined,
435
444
  hostId: r.host_id ?? undefined,
@@ -23,6 +23,7 @@ import { deleteQueued, enqueue, listQueue } from "../chat/queued";
23
23
  import { sendQueuedNow } from "../chat/runner";
24
24
  import { hasItem } from "../chat/store";
25
25
  import { firstMessageId, launchPending } from "./launch-gate";
26
+ import { planPrompt, preparePlanDir } from "../models/plan-step";
26
27
 
27
28
  export { firstMessageId, launchPending };
28
29
  import { expandHome } from "./session";
@@ -136,7 +137,10 @@ export async function finishTaskStart(
136
137
  ms: setup.ms,
137
138
  error: "Not launched: the task was closed during setup",
138
139
  });
139
- const prompt = (session.task_prompt ?? "") + setupNote(setup);
140
+ const task = (session.task_prompt ?? "") + setupNote(setup);
141
+ const planning = session.plan_phase === "planning";
142
+ if (planning) preparePlanDir(session.worktree_path);
143
+ const prompt = planning ? planPrompt(task) : task;
140
144
  try {
141
145
  if (session.view === "chat") await launchChat(sessionId, prompt);
142
146
  else {
@@ -148,6 +152,7 @@ export async function finishTaskStart(
148
152
  tmuxName: session.tmux_name,
149
153
  cwd: session.worktree_path,
150
154
  model: session.model,
155
+ provider: session.model_provider,
151
156
  prompt,
152
157
  brief: session.task_brief ?? undefined,
153
158
  });
@@ -190,7 +195,7 @@ function earlierSetup(row: SetupColumns): TaskSetup | undefined {
190
195
  return undefined;
191
196
  }
192
197
 
193
- const killTmux = (name: string) =>
198
+ export const killTmux = (name: string) =>
194
199
  execFileAsync("tmux", ["kill-session", "-t", `=${name}`], {
195
200
  timeout: 5000,
196
201
  }).catch(() => undefined);
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Spend by model and by role over a window: tokens of each kind, turns,
3
+ * and the estimated cost from the price table (lib/usage/cost).
4
+ */
5
+
6
+ import { estimateCost, rowModel, UNRECORDED, type PricedRow } from "./cost";
7
+ import type { ModelPrice } from "../models/providers";
8
+
9
+ export interface ModelTurnRow extends PricedRow {
10
+ at: number;
11
+ role: string | null;
12
+ }
13
+
14
+ export interface TokenSpend {
15
+ inputTokens: number;
16
+ outputTokens: number;
17
+ cacheReadTokens: number;
18
+ cacheWriteTokens: number;
19
+ turns: number;
20
+ costUsd: number;
21
+ // Some of its turns are on a model the price table doesn't list.
22
+ unpriced: boolean;
23
+ }
24
+
25
+ export interface ModelSpend extends TokenSpend {
26
+ provider: string;
27
+ model: string;
28
+ }
29
+
30
+ export interface RoleSpend extends TokenSpend {
31
+ role: string;
32
+ }
33
+
34
+ export interface SpendByModel {
35
+ since: number;
36
+ total: TokenSpend;
37
+ models: ModelSpend[];
38
+ roles: RoleSpend[];
39
+ }
40
+
41
+ export const emptySpend = (): TokenSpend => ({
42
+ inputTokens: 0,
43
+ outputTokens: 0,
44
+ cacheReadTokens: 0,
45
+ cacheWriteTokens: 0,
46
+ turns: 0,
47
+ costUsd: 0,
48
+ unpriced: false,
49
+ });
50
+
51
+ export function addRow(
52
+ s: TokenSpend,
53
+ r: PricedRow,
54
+ prices: Record<string, ModelPrice>
55
+ ): void {
56
+ const cost = estimateCost(r, prices);
57
+ s.inputTokens += r.input_tokens;
58
+ s.outputTokens += r.output_tokens;
59
+ s.cacheReadTokens += r.cache_read_tokens;
60
+ s.cacheWriteTokens += r.cache_write_tokens;
61
+ s.turns += 1;
62
+ s.costUsd += cost.usd;
63
+ s.unpriced ||= !cost.priced;
64
+ }
65
+
66
+ export function spendByModel(
67
+ rows: ModelTurnRow[],
68
+ since: number,
69
+ prices: Record<string, ModelPrice>
70
+ ): SpendByModel {
71
+ const total = emptySpend();
72
+ const models = new Map<string, ModelSpend>();
73
+ const roles = new Map<string, RoleSpend>();
74
+ for (const r of rows) {
75
+ if (r.at < since) continue;
76
+ const { provider, model } = rowModel(r);
77
+ const mk = `${provider}/${model}`;
78
+ if (!models.has(mk)) models.set(mk, { ...emptySpend(), provider, model });
79
+ const role = r.role ?? UNRECORDED;
80
+ if (!roles.has(role)) roles.set(role, { ...emptySpend(), role });
81
+ for (const s of [total, models.get(mk)!, roles.get(role)!])
82
+ addRow(s, r, prices);
83
+ }
84
+ const byCost = (a: TokenSpend, b: TokenSpend) => b.costUsd - a.costUsd;
85
+ return {
86
+ since,
87
+ total,
88
+ models: [...models.values()].sort(byCost),
89
+ roles: [...roles.values()].sort(byCost),
90
+ };
91
+ }
92
+
93
+ export function sumSpend(xs: TokenSpend[]): TokenSpend {
94
+ const t = emptySpend();
95
+ for (const x of xs) {
96
+ t.inputTokens += x.inputTokens;
97
+ t.outputTokens += x.outputTokens;
98
+ t.cacheReadTokens += x.cacheReadTokens;
99
+ t.cacheWriteTokens += x.cacheWriteTokens;
100
+ t.turns += x.turns;
101
+ t.costUsd += x.costUsd;
102
+ t.unpriced ||= x.unpriced;
103
+ }
104
+ return t;
105
+ }
@@ -10,6 +10,8 @@ import { formatTokens } from "../chat/context";
10
10
 
11
11
  export interface RunUsage {
12
12
  model: string | null;
13
+ // Where the model ran (lib/models); Anthropic when absent.
14
+ provider?: string;
13
15
  inputTokens: number;
14
16
  cacheWriteTokens: number;
15
17
  cacheReadTokens: number;
@@ -92,9 +94,9 @@ export function recordCheckRun(
92
94
  at = Date.now()
93
95
  ): void {
94
96
  db.prepare(
95
- `INSERT INTO check_runs (workspace_id, session_id, label, pr_number, sha, kind, model,
97
+ `INSERT INTO check_runs (workspace_id, session_id, label, pr_number, sha, kind, model, provider,
96
98
  input_tokens, cache_write_tokens, cache_read_tokens, output_tokens, cost_usd, duration_ms, at)
97
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
99
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
98
100
  ).run(
99
101
  target.workspaceId,
100
102
  target.sessionId,
@@ -103,6 +105,7 @@ export function recordCheckRun(
103
105
  target.sha,
104
106
  target.kind,
105
107
  u.model,
108
+ u.provider ?? "anthropic",
106
109
  u.inputTokens,
107
110
  u.cacheWriteTokens,
108
111
  u.cacheReadTokens,
@@ -0,0 +1,280 @@
1
+ /**
2
+ * Claude-only tasks against routed ones, side by side: what each cost
3
+ * (tokens, dollars at list prices, share of the plan's window, wall time)
4
+ * and how it went (CI green on the first push, the first review passing,
5
+ * gate failures, commits after the first review, merged or dropped). Built
6
+ * on the usage ledgers (chat_turns, and check_runs for the gate's own runs)
7
+ * and the orchestrator's checks: no record of its own.
8
+ *
9
+ * A task's cohort is the provider its own turns (planning and implementing)
10
+ * ran on: anthropic is the baseline; one that used two is "mixed". Its
11
+ * reviews and scope checks count toward its cost, whatever they ran on.
12
+ */
13
+
14
+ import { db } from "../db";
15
+ import { effectiveModels } from "../models/config";
16
+ import { ANTHROPIC } from "../models/roles";
17
+ import type { ModelPrice } from "../models/providers";
18
+ import { planShares, rowModel, UNRECORDED } from "./cost";
19
+ import {
20
+ addRow,
21
+ emptySpend,
22
+ type ModelTurnRow,
23
+ type TokenSpend,
24
+ } from "./by-model";
25
+
26
+ export const SMALL_SAMPLE = 10;
27
+ const CHECK_ROLES = new Set(["review", "scope", "helper"]);
28
+
29
+ export interface CompareTurn extends ModelTurnRow {
30
+ session_id: string;
31
+ duration_ms: number | null;
32
+ window_pct: number | null;
33
+ }
34
+
35
+ export interface TaskOutcome {
36
+ id: string;
37
+ status: string | null;
38
+ // The first finished CI run, and review, on its PR: passed or not; null
39
+ // when it never had one (no PR, or not under an orchestrator).
40
+ ciFirstGreen: boolean | null;
41
+ reviewFirstPass: boolean | null;
42
+ gateFailures: number;
43
+ // Heads checked after its first review: the rework the review caused.
44
+ rework: number;
45
+ }
46
+
47
+ export interface Rate {
48
+ yes: number;
49
+ of: number;
50
+ }
51
+
52
+ export interface Cohort {
53
+ key: string;
54
+ baseline: boolean;
55
+ tasks: number;
56
+ smallSample: boolean;
57
+ spend: TokenSpend;
58
+ // Points of the plan's 5-hour window (an estimate), Anthropic turns only.
59
+ planPct: number;
60
+ wallMs: number;
61
+ ciFirstGreen: Rate;
62
+ reviewFirstPass: Rate;
63
+ gateFailures: number;
64
+ rework: number;
65
+ merged: Rate;
66
+ dropped: Rate;
67
+ }
68
+
69
+ export interface RoleModelRow extends TokenSpend {
70
+ role: string;
71
+ provider: string;
72
+ model: string;
73
+ planPct: number;
74
+ wallMs: number;
75
+ }
76
+
77
+ export interface Comparison {
78
+ from: number;
79
+ to: number;
80
+ cohorts: Cohort[];
81
+ roles: RoleModelRow[];
82
+ }
83
+
84
+ const rate = (): Rate => ({ yes: 0, of: 0 });
85
+ const count = (r: Rate, v: boolean | null) => {
86
+ if (v === null) return;
87
+ r.of += 1;
88
+ if (v) r.yes += 1;
89
+ };
90
+
91
+ function cohortOf(turns: CompareTurn[]): string {
92
+ const providers = new Set(
93
+ turns
94
+ .filter((t) => !CHECK_ROLES.has(t.role ?? ""))
95
+ .map((t) => rowModel(t).provider)
96
+ );
97
+ if (providers.size === 0) return ANTHROPIC;
98
+ return providers.size === 1 ? [...providers][0] : "mixed";
99
+ }
100
+
101
+ export function compareRuns(
102
+ turns: CompareTurn[],
103
+ outcomes: Map<string, TaskOutcome>,
104
+ prices: Record<string, ModelPrice>,
105
+ range: { from: number; to: number }
106
+ ): Comparison {
107
+ const shares = planShares(turns);
108
+ const share = new Map(turns.map((t, i) => [t, shares[i]]));
109
+ const inRange = turns.filter((t) => t.at >= range.from && t.at <= range.to);
110
+
111
+ const byTask = new Map<string, CompareTurn[]>();
112
+ for (const t of inRange)
113
+ if (outcomes.has(t.session_id))
114
+ byTask.set(t.session_id, [...(byTask.get(t.session_id) ?? []), t]);
115
+
116
+ const cohorts = new Map<string, Cohort>();
117
+ for (const [id, ts] of byTask) {
118
+ const key = cohortOf(ts);
119
+ let c = cohorts.get(key);
120
+ if (!c)
121
+ cohorts.set(
122
+ key,
123
+ (c = {
124
+ key,
125
+ baseline: key === ANTHROPIC,
126
+ tasks: 0,
127
+ smallSample: true,
128
+ spend: emptySpend(),
129
+ planPct: 0,
130
+ wallMs: 0,
131
+ ciFirstGreen: rate(),
132
+ reviewFirstPass: rate(),
133
+ gateFailures: 0,
134
+ rework: 0,
135
+ merged: rate(),
136
+ dropped: rate(),
137
+ })
138
+ );
139
+ const o = outcomes.get(id)!;
140
+ c.tasks += 1;
141
+ for (const t of ts) {
142
+ addRow(c.spend, t, prices);
143
+ c.planPct += share.get(t) ?? 0;
144
+ c.wallMs += t.duration_ms ?? 0;
145
+ }
146
+ count(c.ciFirstGreen, o.ciFirstGreen);
147
+ count(c.reviewFirstPass, o.reviewFirstPass);
148
+ c.gateFailures += o.gateFailures;
149
+ c.rework += o.rework;
150
+ // Still running counts toward neither.
151
+ const finished = o.status !== "running" && o.status !== null;
152
+ count(c.merged, finished ? o.status === "merged" : null);
153
+ count(c.dropped, finished ? o.status === "dropped" : null);
154
+ }
155
+ for (const c of cohorts.values()) c.smallSample = c.tasks < SMALL_SAMPLE;
156
+
157
+ const roles = new Map<string, RoleModelRow>();
158
+ for (const t of inRange) {
159
+ const { provider, model } = rowModel(t);
160
+ const role = t.role ?? UNRECORDED;
161
+ const k = `${role} ${provider}/${model}`;
162
+ let r = roles.get(k);
163
+ if (!r)
164
+ roles.set(
165
+ k,
166
+ (r = { ...emptySpend(), role, provider, model, planPct: 0, wallMs: 0 })
167
+ );
168
+ addRow(r, t, prices);
169
+ r.planPct += share.get(t) ?? 0;
170
+ r.wallMs += t.duration_ms ?? 0;
171
+ }
172
+
173
+ return {
174
+ ...range,
175
+ // The baseline first, then the most tasks.
176
+ cohorts: [...cohorts.values()].sort(
177
+ (a, b) => Number(b.baseline) - Number(a.baseline) || b.tasks - a.tasks
178
+ ),
179
+ roles: [...roles.values()].sort(
180
+ (a, b) => a.role.localeCompare(b.role) || b.costUsd - a.costUsd
181
+ ),
182
+ };
183
+ }
184
+
185
+ // The ledger's turns, with the gate reviews and scope checks recorded in
186
+ // check_runs (lib/usage/check-runs) as turns of their own role.
187
+ export const LEDGER_SQL = `
188
+ SELECT session_id, at, cost_usd, input_tokens, output_tokens,
189
+ cache_read_tokens, cache_write_tokens, model, provider, role, duration_ms,
190
+ window_pct FROM chat_turns WHERE at >= @from AND at <= @to
191
+ UNION ALL
192
+ SELECT session_id, at, cost_usd, input_tokens, output_tokens,
193
+ cache_read_tokens, cache_write_tokens, model, provider, kind AS role,
194
+ duration_ms, NULL AS window_pct FROM check_runs WHERE at >= @from AND at <= @to
195
+ ORDER BY at`;
196
+
197
+ // The outcome of each task with turns in the range.
198
+ export function taskOutcomes(ids: string[]): Map<string, TaskOutcome> {
199
+ const out = new Map<string, TaskOutcome>();
200
+ if (!ids.length) return out;
201
+ const marks = ids.map(() => "?").join(",");
202
+ const tasks = db
203
+ .prepare(
204
+ `SELECT id, task_status FROM sessions
205
+ WHERE id IN (${marks}) AND task_status IS NOT NULL`
206
+ )
207
+ .all(...ids) as { id: string; task_status: string }[];
208
+ const checks = db
209
+ .prepare(
210
+ `SELECT id, session_id, sha, kind, status FROM orchestrator_checks
211
+ WHERE session_id IN (${marks}) ORDER BY id`
212
+ )
213
+ .all(...ids) as {
214
+ id: number;
215
+ session_id: string;
216
+ sha: string;
217
+ kind: string;
218
+ status: string;
219
+ }[];
220
+ const gates = db
221
+ .prepare(
222
+ `SELECT session_id, SUM(count) AS n FROM orchestrator_gate_failures
223
+ WHERE session_id IN (${marks}) GROUP BY session_id`
224
+ )
225
+ .all(...ids) as { session_id: string; n: number }[];
226
+ for (const t of tasks)
227
+ out.set(t.id, {
228
+ id: t.id,
229
+ status: t.task_status,
230
+ ciFirstGreen: null,
231
+ reviewFirstPass: null,
232
+ gateFailures: 0,
233
+ rework: 0,
234
+ });
235
+ const firstReview = new Map<string, number>();
236
+ const reworked = new Map<string, Set<string>>();
237
+ for (const c of checks) {
238
+ const o = out.get(c.session_id);
239
+ if (!o) continue;
240
+ const settled = c.status === "pass" || c.status === "block";
241
+ if (c.kind === "ci" && settled && o.ciFirstGreen === null)
242
+ o.ciFirstGreen = c.status === "pass";
243
+ if (c.kind === "review" && settled && o.reviewFirstPass === null) {
244
+ o.reviewFirstPass = c.status === "pass";
245
+ firstReview.set(c.session_id, c.id);
246
+ }
247
+ const after = firstReview.get(c.session_id);
248
+ if (after !== undefined && c.id > after) {
249
+ const shas = reworked.get(c.session_id) ?? new Set();
250
+ shas.add(c.sha);
251
+ reworked.set(c.session_id, shas);
252
+ }
253
+ }
254
+ for (const [id, shas] of reworked) {
255
+ // The reviewed head itself isn't rework.
256
+ const o = out.get(id)!;
257
+ const reviewed = checks.find((c) => c.id === firstReview.get(id))?.sha;
258
+ o.rework = [...shas].filter((s) => s !== reviewed).length;
259
+ }
260
+ for (const g of gates) {
261
+ const o = out.get(g.session_id);
262
+ if (o) o.gateFailures = g.n;
263
+ }
264
+ return out;
265
+ }
266
+
267
+ export function comparisonReport(from: number, to: number): Comparison {
268
+ // From a day earlier, so the first turn in range has a reading before it
269
+ // to measure its share of the window from.
270
+ const turns = db
271
+ .prepare(LEDGER_SQL)
272
+ .all({ from: from - 24 * 3600 * 1000, to }) as CompareTurn[];
273
+ const ids = [
274
+ ...new Set(turns.filter((t) => t.at >= from).map((t) => t.session_id)),
275
+ ];
276
+ return compareRuns(turns, taskOutcomes(ids), effectiveModels().prices, {
277
+ from,
278
+ to,
279
+ });
280
+ }