@a-t-h-i/bot-lobby 0.4.0 → 0.5.0

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.
@@ -0,0 +1,305 @@
1
+ /**
2
+ * Model performance records: one line per finished run of any agent — the
3
+ * Master's own turns, scouts, workers, the QA gate, researchers, quick fixes,
4
+ * planner turns and planning panel seats — so the lobby can show how long each model takes at each
5
+ * thinking level, how often it succeeds and what it costs. Append-only JSON
6
+ * lines per project; reads keep the newest `MAX_READ` records.
7
+ */
8
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
9
+ import { dirname, join } from "node:path";
10
+ import type { AgentRun } from "../schemas/findings.ts";
11
+ import type { RunLogEntry, Task } from "../schemas/task.ts";
12
+ import { dataRoot } from "./project.ts";
13
+
14
+ export const METRIC_KINDS = ["master", "scout", "worker", "reviewer", "researcher", "quickfix", "planner", "panel"] as const;
15
+ export type MetricKind = (typeof METRIC_KINDS)[number];
16
+
17
+ export type MetricStatus = "success" | "failed" | "cancelled" | "timeout";
18
+
19
+ export interface MetricRecord {
20
+ id: string;
21
+ kind: MetricKind;
22
+ /** Display name of the agent: MASTER, DEV, DESIGN, QA, RESEARCH, QUICK FIX, ORACLE (planning). */
23
+ agent: string;
24
+ model?: string;
25
+ thinking?: string;
26
+ status: MetricStatus;
27
+ startedAt: string;
28
+ durationMs: number;
29
+ turns?: number;
30
+ tools?: number;
31
+ input?: number;
32
+ output?: number;
33
+ cost?: number;
34
+ taskId?: string;
35
+ stalled?: boolean;
36
+ }
37
+
38
+ /** Newest records kept in memory for aggregation. */
39
+ export const MAX_READ = 5000;
40
+
41
+ export function metricsPath(root: string, configDir: string): string {
42
+ return join(dataRoot(root, configDir), "metrics.jsonl");
43
+ }
44
+
45
+ export function appendMetrics(root: string, configDir: string, records: readonly MetricRecord[]): void {
46
+ if (records.length === 0) return;
47
+ const path = metricsPath(root, configDir);
48
+ try {
49
+ mkdirSync(dirname(path), { recursive: true });
50
+ appendFileSync(path, records.map((record) => `${JSON.stringify(record)}\n`).join(""), "utf8");
51
+ } catch {
52
+ // Metrics are best-effort; a read-only tree must never fail a workflow step.
53
+ }
54
+ }
55
+
56
+ function isRecord(value: unknown): value is MetricRecord {
57
+ const record = value as Partial<MetricRecord> | undefined;
58
+ return Boolean(record && typeof record.id === "string" && typeof record.kind === "string" && typeof record.durationMs === "number");
59
+ }
60
+
61
+ export function readMetrics(root: string, configDir: string, limit = MAX_READ): MetricRecord[] {
62
+ const path = metricsPath(root, configDir);
63
+ if (!existsSync(path)) return [];
64
+ let text: string;
65
+ try {
66
+ text = readFileSync(path, "utf8");
67
+ } catch {
68
+ return [];
69
+ }
70
+ const records: MetricRecord[] = [];
71
+ for (const line of text.split("\n").slice(-limit - 1)) {
72
+ if (!line.trim()) continue;
73
+ try {
74
+ const value = JSON.parse(line) as unknown;
75
+ if (isRecord(value)) records.push(value);
76
+ } catch {
77
+ // Torn lines are skipped.
78
+ }
79
+ }
80
+ return records.slice(-limit);
81
+ }
82
+
83
+ const AGENT_NAMES: Record<string, string> = { backend: "DEV", designer: "DESIGN", qa: "QA" };
84
+
85
+ function durationBetween(startedAt: string, finishedAt: string | undefined): number {
86
+ if (!finishedAt) return 0;
87
+ const ms = Date.parse(finishedAt) - Date.parse(startedAt);
88
+ return Number.isFinite(ms) && ms > 0 ? ms : 0;
89
+ }
90
+
91
+ function finalStatus(status: AgentRun["status"]): MetricStatus {
92
+ return status === "running" ? "failed" : status;
93
+ }
94
+
95
+ /** A finished subagent run as a metric record. */
96
+ export function metricFromRun(run: AgentRun): MetricRecord {
97
+ const agent = run.role === "researcher" ? "RESEARCH" : AGENT_NAMES[run.domain] ?? run.domain.toUpperCase();
98
+ const turns = run.turns ?? run.usage?.turns;
99
+ return {
100
+ id: run.runId,
101
+ kind: run.role,
102
+ agent,
103
+ ...(run.model ? { model: run.model } : {}),
104
+ ...(run.thinking ? { thinking: run.thinking } : {}),
105
+ status: finalStatus(run.status),
106
+ startedAt: run.startedAt,
107
+ durationMs: durationBetween(run.startedAt, run.finishedAt),
108
+ ...(turns ? { turns } : {}),
109
+ ...(run.tools ? { tools: run.tools } : {}),
110
+ ...(run.usage ? { input: run.usage.input, output: run.usage.output, cost: run.usage.cost } : {}),
111
+ taskId: run.taskId,
112
+ ...(run.stalled ? { stalled: true } : {}),
113
+ };
114
+ }
115
+
116
+ /** A persisted run-log entry as a metric record, so runs from before the metrics log still count. */
117
+ export function metricFromLog(entry: RunLogEntry, taskId: string): MetricRecord {
118
+ const agent = entry.role === "researcher" ? "RESEARCH" : AGENT_NAMES[entry.domain] ?? entry.domain.toUpperCase();
119
+ return {
120
+ id: entry.runId,
121
+ kind: entry.role,
122
+ agent,
123
+ ...(entry.model ? { model: entry.model } : {}),
124
+ ...(entry.thinking ? { thinking: entry.thinking } : {}),
125
+ status: finalStatus(entry.status),
126
+ startedAt: entry.startedAt,
127
+ durationMs: durationBetween(entry.startedAt, entry.finishedAt),
128
+ ...(entry.turns ? { turns: entry.turns } : {}),
129
+ ...(entry.tools ? { tools: entry.tools } : {}),
130
+ ...(entry.input !== undefined ? { input: entry.input, output: entry.output ?? 0, cost: entry.cost ?? 0 } : {}),
131
+ taskId,
132
+ ...(entry.stalled ? { stalled: true } : {}),
133
+ };
134
+ }
135
+
136
+ /** Records from the log plus every task's run log, deduplicated by id (the log wins). */
137
+ export function collectMetrics(logged: readonly MetricRecord[], tasks: readonly Task[]): MetricRecord[] {
138
+ const byId = new Map<string, MetricRecord>();
139
+ for (const task of tasks) for (const entry of task.runLog ?? []) byId.set(entry.runId, metricFromLog(entry, task.id));
140
+ for (const record of logged) byId.set(record.id, record);
141
+ return [...byId.values()].sort((a, b) => a.startedAt.localeCompare(b.startedAt));
142
+ }
143
+
144
+ export interface MetricGroup {
145
+ /** The model id without its provider, or "unknown" when a run never reported one. */
146
+ model: string;
147
+ thinking: string;
148
+ /** Agent kinds seen in the group, most frequent first. */
149
+ kinds: MetricKind[];
150
+ runs: number;
151
+ successes: number;
152
+ /** Runs that stalled or hit their time limit. */
153
+ timeouts: number;
154
+ avgMs: number;
155
+ p50Ms: number;
156
+ p90Ms: number;
157
+ avgTurns: number;
158
+ avgTools: number;
159
+ avgTokens: number;
160
+ avgCost: number;
161
+ totalCost: number;
162
+ /** Output tokens per second of wall time, a rough throughput signal. */
163
+ tokensPerSecond: number;
164
+ }
165
+
166
+ export type GroupBy = "model" | "model-kind";
167
+
168
+ function percentile(sorted: readonly number[], fraction: number): number {
169
+ if (sorted.length === 0) return 0;
170
+ const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(fraction * sorted.length) - 1));
171
+ return sorted[index]!;
172
+ }
173
+
174
+ function mean(values: readonly number[]): number {
175
+ return values.length === 0 ? 0 : values.reduce((sum, value) => sum + value, 0) / values.length;
176
+ }
177
+
178
+ /**
179
+ * The model a record ran on, without its provider: the Master reports
180
+ * `provider/id` while subagent streams report the bare id, and both are the
181
+ * same model.
182
+ */
183
+ export function modelName(model: string | undefined): string {
184
+ if (!model) return "unknown";
185
+ const slash = model.indexOf("/");
186
+ return slash >= 0 ? model.slice(slash + 1) : model;
187
+ }
188
+
189
+ function groupKey(record: MetricRecord, by: GroupBy): string {
190
+ const base = `${modelName(record.model)}\0${record.thinking ?? "—"}`;
191
+ return by === "model-kind" ? `${base}\0${record.kind}` : base;
192
+ }
193
+
194
+ /**
195
+ * Aggregate records per model and thinking level (optionally split by agent
196
+ * kind). Cancelled runs say nothing about a model's speed, so they count as
197
+ * runs but stay out of the timing and throughput figures.
198
+ */
199
+ export function aggregateMetrics(records: readonly MetricRecord[], by: GroupBy = "model"): MetricGroup[] {
200
+ const groups = new Map<string, MetricRecord[]>();
201
+ for (const record of records) {
202
+ const key = groupKey(record, by);
203
+ const list = groups.get(key);
204
+ if (list) list.push(record);
205
+ else groups.set(key, [record]);
206
+ }
207
+ return [...groups.values()].map((list) => {
208
+ const timed = list.filter((record) => record.status !== "cancelled" && record.durationMs > 0);
209
+ const durations = timed.map((record) => record.durationMs).sort((a, b) => a - b);
210
+ const kindCounts = new Map<MetricKind, number>();
211
+ for (const record of list) kindCounts.set(record.kind, (kindCounts.get(record.kind) ?? 0) + 1);
212
+ const totalCost = list.reduce((sum, record) => sum + (record.cost ?? 0), 0);
213
+ const outputTokens = timed.reduce((sum, record) => sum + (record.output ?? 0), 0);
214
+ const seconds = durations.reduce((sum, ms) => sum + ms, 0) / 1000;
215
+ return {
216
+ model: modelName(list[0]!.model),
217
+ thinking: list[0]!.thinking ?? "—",
218
+ kinds: [...kindCounts.entries()].sort((a, b) => b[1] - a[1]).map(([kind]) => kind),
219
+ runs: list.length,
220
+ successes: list.filter((record) => record.status === "success").length,
221
+ timeouts: list.filter((record) => record.status === "timeout" || record.stalled).length,
222
+ avgMs: mean(durations),
223
+ p50Ms: percentile(durations, 0.5),
224
+ p90Ms: percentile(durations, 0.9),
225
+ avgTurns: mean(list.filter((record) => record.turns).map((record) => record.turns!)),
226
+ avgTools: mean(list.filter((record) => record.tools !== undefined).map((record) => record.tools!)),
227
+ avgTokens: mean(list.filter((record) => record.input !== undefined).map((record) => (record.input ?? 0) + (record.output ?? 0))),
228
+ avgCost: list.length > 0 ? totalCost / list.length : 0,
229
+ totalCost,
230
+ tokensPerSecond: seconds > 0 ? outputTokens / seconds : 0,
231
+ };
232
+ });
233
+ }
234
+
235
+ export type SortKey = "runs" | "avg" | "success" | "cost";
236
+ export const SORT_KEYS: readonly SortKey[] = ["runs", "avg", "success", "cost"];
237
+
238
+ export function sortGroups(groups: readonly MetricGroup[], key: SortKey): MetricGroup[] {
239
+ const score = (group: MetricGroup): number => {
240
+ if (key === "avg") return group.avgMs;
241
+ if (key === "success") return group.runs === 0 ? 0 : group.successes / group.runs;
242
+ if (key === "cost") return group.totalCost;
243
+ return group.runs;
244
+ };
245
+ // Fastest first for time; largest first otherwise.
246
+ const direction = key === "avg" ? 1 : -1;
247
+ return [...groups].sort((a, b) => direction * (score(a) - score(b)) || a.model.localeCompare(b.model));
248
+ }
249
+
250
+ export interface TaskStats {
251
+ completed: number;
252
+ abandoned: number;
253
+ active: number;
254
+ /** Mean wall time from creation to completion over completed tasks. */
255
+ avgCompleteMs: number;
256
+ }
257
+
258
+ export function taskStats(tasks: readonly Task[]): TaskStats {
259
+ const completed = tasks.filter((task) => task.state === "completed");
260
+ const times = completed.map((task) => durationBetween(task.createdAt, task.updatedAt)).filter((ms) => ms > 0);
261
+ return {
262
+ completed: completed.length,
263
+ abandoned: tasks.filter((task) => task.state === "abandoned").length,
264
+ active: tasks.filter((task) => task.state !== "completed" && task.state !== "abandoned").length,
265
+ avgCompleteMs: mean(times),
266
+ };
267
+ }
268
+
269
+ export interface TaskTimeGroup {
270
+ model: string;
271
+ thinking: string;
272
+ tasks: number;
273
+ avgMs: number;
274
+ }
275
+
276
+ /**
277
+ * How long completed tasks took from request to done, grouped by the model and
278
+ * thinking level the oracle ran most of its turns on for that task. Tasks with
279
+ * no recorded Master turn are left out.
280
+ */
281
+ export function taskTimesByModel(tasks: readonly Task[], records: readonly MetricRecord[]): TaskTimeGroup[] {
282
+ const masterTurns = new Map<string, Map<string, number>>();
283
+ for (const record of records) {
284
+ if (record.kind !== "master" || !record.taskId) continue;
285
+ const key = `${modelName(record.model)}\0${record.thinking ?? "—"}`;
286
+ const counts = masterTurns.get(record.taskId) ?? new Map<string, number>();
287
+ counts.set(key, (counts.get(key) ?? 0) + 1);
288
+ masterTurns.set(record.taskId, counts);
289
+ }
290
+ const groups = new Map<string, number[]>();
291
+ for (const task of tasks) {
292
+ if (task.state !== "completed") continue;
293
+ const counts = masterTurns.get(task.id);
294
+ const ms = durationBetween(task.createdAt, task.updatedAt);
295
+ if (!counts || ms <= 0) continue;
296
+ const key = [...counts.entries()].sort((a, b) => b[1] - a[1])[0]![0];
297
+ groups.set(key, [...(groups.get(key) ?? []), ms]);
298
+ }
299
+ return [...groups.entries()]
300
+ .map(([key, times]) => {
301
+ const [model, thinking] = key.split("\0");
302
+ return { model: model!, thinking: thinking!, tasks: times.length, avgMs: mean(times) };
303
+ })
304
+ .sort((a, b) => b.tasks - a.tasks || a.avgMs - b.avgMs);
305
+ }
@@ -46,6 +46,8 @@ import { assertNoPendingApprovals, pendingApprovals, requestApproval, resolveApp
46
46
  import { pingApproval } from "../pi/notify.ts";
47
47
  import { describeRun, runLogEntry } from "../pi/run-summary.ts";
48
48
  import { nextStates } from "./transitions.ts";
49
+ import { appendMetrics, metricFromRun } from "../state/metrics.ts";
50
+ import { markCommentsAddressed, pendingComments, readPlanComments } from "../state/comments.ts";
49
51
 
50
52
  export const ORCHESTRATE_ACTIONS = [
51
53
  "clarify",
@@ -387,6 +389,8 @@ async function handlePropose(task: Task, params: OrchestrateParams, deps: Workfl
387
389
  if (task.state === "clarifying") transition(task, "awaiting_approval");
388
390
  task.proposal = proposal;
389
391
  writeFileEnsured(join(taskDirFor(deps.root, deps.configDir, task.id), "proposal.md"), proposal);
392
+ // A new proposal answers any lobby comments left on the previous one.
393
+ if (!task.plan) addressComments(task, deps);
390
394
  for (const concern of params.concerns ?? []) recordDecision(task, `Concern: ${concern}`);
391
395
  transition(task, "awaiting_approval");
392
396
  if (!deps.config.workflow.requireApprovalForFeatures) return applyApprovalChoice(task, "approve");
@@ -398,15 +402,40 @@ async function handlePropose(task: Task, params: OrchestrateParams, deps: Workfl
398
402
  return applyApprovalChoice(task, "amend", await deps.ask("What should change?"));
399
403
  }
400
404
 
405
+ /** States in which `plan` replaces an approved plan instead of recording the first one. */
406
+ const AMEND_PLAN_STATES: readonly TaskState[] = ["implementing", "reviewing"];
407
+
408
+ /** Mark the user's outstanding lobby comments on this task as addressed; returns how many. */
409
+ function addressComments(task: Task, deps: WorkflowDeps): number {
410
+ const pending = pendingComments(readPlanComments(deps.root, deps.configDir, task.id));
411
+ if (pending.length > 0) markCommentsAddressed(deps.root, deps.configDir, task.id, pending.map((comment) => comment.id));
412
+ return pending.length;
413
+ }
414
+
415
+ function commentsNote(count: number): string {
416
+ return count > 0 ? ` ${count} lobby comment${count === 1 ? "" : "s"} marked addressed.` : "";
417
+ }
418
+
419
+ /**
420
+ * Record the internal plan while planning, or amend it later (for example when
421
+ * the user comments on it from the lobby): an amendment replaces the plan and
422
+ * keeps the task where it is, so finished steps stay done.
423
+ */
401
424
  function handlePlan(task: Task, params: OrchestrateParams, deps: WorkflowDeps): string {
402
- requireState(task, ["planning"]);
425
+ requireState(task, ["planning", ...AMEND_PLAN_STATES]);
403
426
  const plan = params.plan?.trim();
404
427
  if (!plan) throw new Error("plan requires the plan text");
405
428
  const missing = validatePlan(plan);
406
429
  if (missing.length > 0) throw new Error(`plan is missing: ${missing.join(", ")}`);
430
+ const amending = AMEND_PLAN_STATES.includes(task.state);
407
431
  task.plan = plan;
408
432
  writeFileEnsured(join(taskDirFor(deps.root, deps.configDir, task.id), "plan.md"), plan);
409
- return "Plan recorded. Next: call action=implement with domain and task for the first step.";
433
+ const addressed = addressComments(task, deps);
434
+ if (amending) {
435
+ recordDecision(task, `Plan amended${addressed > 0 ? ` for ${addressed} user comment${addressed === 1 ? "" : "s"}` : ""}.`);
436
+ return `Plan amended.${commentsNote(addressed)} Next: continue with action=implement for the next open step (or action=qa when the work is complete).`;
437
+ }
438
+ return `Plan recorded.${commentsNote(addressed)} Next: call action=implement with domain and task for the first step.`;
410
439
  }
411
440
 
412
441
  /** Record approvals a worker asked for; auto-approve when config allows it. */
@@ -888,20 +917,23 @@ export async function runWorkflowAction(params: OrchestrateParams, deps: Workflo
888
917
  };
889
918
  try {
890
919
  const message = await handler(task, params, tracked);
891
- const runs = recordRunLog(task, finished);
920
+ const runs = recordRunLog(task, finished, deps);
892
921
  saveTask(deps.root, deps.configDir, task);
893
922
  return { ok: true, taskId: task.id, state: task.state, message: `${message}${runsFooter(runs)}`, runs };
894
923
  } catch (error) {
895
- const runs = recordRunLog(task, finished);
924
+ const runs = recordRunLog(task, finished, deps);
896
925
  saveTask(deps.root, deps.configDir, task);
897
926
  return { ok: false, taskId: task.id, state: task.state, message: `Rejected: ${(error as Error).message}`, runs };
898
927
  }
899
928
  }
900
929
 
901
- /** Append this action's finished runs to the task's bounded run log. */
902
- function recordRunLog(task: Task, finished: ReadonlyMap<string, AgentRun>): AgentRun[] {
930
+ /** Append this action's finished runs to the task's bounded run log and the project's metrics log. */
931
+ function recordRunLog(task: Task, finished: ReadonlyMap<string, AgentRun>, deps: WorkflowDeps): AgentRun[] {
903
932
  const runs = [...finished.values()];
904
- if (runs.length > 0) task.runLog = [...(task.runLog ?? []), ...runs.map(runLogEntry)].slice(-MAX_RUN_LOG);
933
+ if (runs.length > 0) {
934
+ task.runLog = [...(task.runLog ?? []), ...runs.map(runLogEntry)].slice(-MAX_RUN_LOG);
935
+ appendMetrics(deps.root, deps.configDir, runs.map(metricFromRun));
936
+ }
905
937
  return runs;
906
938
  }
907
939