@a-t-h-i/bot-lobby 0.6.25 → 0.6.26
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.
- package/README.md +3 -1
- package/package.json +1 -5
- package/prompts/master.md +12 -7
- package/prompts/planner.md +5 -2
- package/src/classifier/hosts.ts +0 -11
- package/src/classifier/instance.ts +0 -5
- package/src/execution/agent-runner.ts +0 -18
- package/src/knowledge/compactor.ts +0 -70
- package/src/lobby/ask.ts +0 -15
- package/src/lobby/host.ts +2 -0
- package/src/lobby/issues.ts +0 -8
- package/src/lobby/pulls.ts +1 -44
- package/src/lobby/runtime.ts +1 -1
- package/src/lobby/service.ts +3 -1
- package/src/lobby/session-files.ts +1 -45
- package/src/lobby/split.ts +33 -1
- package/src/lobby/task-rows.ts +2 -0
- package/src/master/research.ts +0 -16
- package/src/pi/events.ts +9 -3
- package/src/pi/model-settings.ts +0 -1
- package/src/pi/plan-checklist.ts +98 -8
- package/src/pi/route.ts +0 -1
- package/src/pi/run-summary.ts +0 -57
- package/src/schemas/agent.ts +0 -4
- package/src/schemas/configuration.ts +0 -2
- package/src/state/budget.ts +49 -22
- package/src/state/metrics.ts +0 -1
- package/src/state/persistence.ts +0 -5
- package/src/state/work-time.ts +123 -0
- package/src/webui/api/index.ts +0 -5
- package/src/webui/api/lobby.ts +13 -6
- package/src/webui/api/plan-facts.ts +36 -6
- package/src/webui/api/tasks.ts +27 -6
- package/src/webui/dev/fake-service.ts +6 -0
- package/src/webui/dev/fixtures.ts +2 -0
- package/src/webui/protocol.ts +20 -3
- package/src/workflow/workflow.ts +35 -8
- package/webui/dist/assets/index-C1IwecNS.css +2 -0
- package/webui/dist/assets/{index-BRVbs5In.js → index-C4Ww36TW.js} +38 -38
- package/webui/dist/build.json +25 -37
- package/webui/dist/index.html +2 -2
- package/webui/dist/assets/index-BeRXVi_r.css +0 -2
package/src/pi/plan-checklist.ts
CHANGED
|
@@ -89,6 +89,19 @@ function stepSection(lines: readonly string[]): string[] | undefined {
|
|
|
89
89
|
return body;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
/**
|
|
93
|
+
* The concise list a plan opens its steps with: a `Steps` section that comes
|
|
94
|
+
* before any `Step N` heading. The plan's detail then follows, often one
|
|
95
|
+
* `### Step N: …` section per step, and the list stays the checklist.
|
|
96
|
+
*/
|
|
97
|
+
function leadingStepList(lines: readonly string[]): string[] {
|
|
98
|
+
const section = lines.findIndex((line) => HEADER_LINE.test(line) && STEP_SECTION.test(line));
|
|
99
|
+
if (section < 0) return [];
|
|
100
|
+
const heading = lines.findIndex((line) => STEP_HEADING.test(line));
|
|
101
|
+
if (heading >= 0 && heading < section) return [];
|
|
102
|
+
return collectSteps(stepSection(lines) ?? [], sectionItem);
|
|
103
|
+
}
|
|
104
|
+
|
|
92
105
|
/** `Step N` headings, when the plan is structured as one heading per step. */
|
|
93
106
|
function headingSteps(lines: readonly string[]): string[] {
|
|
94
107
|
const found: string[] = [];
|
|
@@ -100,13 +113,17 @@ function headingSteps(lines: readonly string[]): string[] {
|
|
|
100
113
|
}
|
|
101
114
|
|
|
102
115
|
/**
|
|
103
|
-
* Step texts from a free-form plan, capped.
|
|
104
|
-
* `
|
|
105
|
-
*
|
|
106
|
-
*
|
|
116
|
+
* Step texts from a free-form plan, capped. A steps list the plan opens with
|
|
117
|
+
* wins (its detail may follow under `Step N` headings); then `Step N`
|
|
118
|
+
* headings; then a `sequence|steps|order` section; then numbered lines; when
|
|
119
|
+
* none exists, top-level bullets are the last resort, so unrelated bullet
|
|
120
|
+
* lists under other headers never leak into the checklist. Only the
|
|
121
|
+
* shallowest items count.
|
|
107
122
|
*/
|
|
108
123
|
export function planSteps(plan: string): string[] {
|
|
109
124
|
const lines = plan.split("\n");
|
|
125
|
+
const leading = leadingStepList(lines);
|
|
126
|
+
if (leading.length > 0) return leading.slice(0, MAX_PLAN_STEPS);
|
|
110
127
|
const headings = headingSteps(lines);
|
|
111
128
|
if (headings.length > 0) return headings.slice(0, MAX_PLAN_STEPS);
|
|
112
129
|
const section = stepSection(lines);
|
|
@@ -119,6 +136,26 @@ export function planSteps(plan: string): string[] {
|
|
|
119
136
|
return collectSteps(lines, topBulletItem).slice(0, MAX_PLAN_STEPS);
|
|
120
137
|
}
|
|
121
138
|
|
|
139
|
+
/**
|
|
140
|
+
* The plan without the steps list it opens with, for reading after the
|
|
141
|
+
* checklist (which already shows those steps); undefined when the checklist
|
|
142
|
+
* does not come from such a list, so the plan is read whole.
|
|
143
|
+
*/
|
|
144
|
+
export function planDetails(plan: string): string | undefined {
|
|
145
|
+
const lines = plan.split("\n");
|
|
146
|
+
if (leadingStepList(lines).length === 0) return undefined;
|
|
147
|
+
const heading = lines.findIndex((line) => HEADER_LINE.test(line) && STEP_SECTION.test(line));
|
|
148
|
+
let to = lines.length;
|
|
149
|
+
for (let index = heading + 1; index < lines.length; index += 1) {
|
|
150
|
+
if (HEADER_LINE.test(lines[index]!)) {
|
|
151
|
+
to = index;
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const rest = [...lines.slice(0, heading), ...lines.slice(to)].join("\n").trim();
|
|
156
|
+
return rest || undefined;
|
|
157
|
+
}
|
|
158
|
+
|
|
122
159
|
/** Where a plan's steps section sits, and each of its top-level steps whole (its nested lines included). */
|
|
123
160
|
export interface StepBlocks {
|
|
124
161
|
/** The section's body: from this line up to (not including) `to`. */
|
|
@@ -239,15 +276,23 @@ function stepScore(step: string, instruction: InstructionIndex): number {
|
|
|
239
276
|
* named, so "building on step 1, now do step 3" does not jump back to step 1.
|
|
240
277
|
*/
|
|
241
278
|
export function explicitStepIndex(instruction: string, count: number): number {
|
|
279
|
+
return explicitStepRange(instruction, count)?.[1] ?? -1;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** The 0-based steps an instruction labels itself with, first and last ("Steps 2-4: ..." is 1 to 3); undefined when none. */
|
|
283
|
+
export function explicitStepRange(instruction: string, count: number): [number, number] | undefined {
|
|
242
284
|
const refs = [...instruction.matchAll(STEP_REFERENCE)];
|
|
243
|
-
if (refs.length === 0) return
|
|
285
|
+
if (refs.length === 0) return undefined;
|
|
244
286
|
const first = refs[0]!;
|
|
245
287
|
const leading = LEADING_LABEL.test(instruction.slice(0, first.index ?? 0)) ? first : undefined;
|
|
246
288
|
const distinct = new Set(refs.map((ref) => ref[0].toLowerCase().replace(/\s+/g, "")));
|
|
247
289
|
const chosen = leading ?? (distinct.size === 1 ? refs[0] : undefined);
|
|
248
|
-
if (!chosen) return
|
|
249
|
-
const
|
|
250
|
-
|
|
290
|
+
if (!chosen) return undefined;
|
|
291
|
+
const a = Number(chosen[1]);
|
|
292
|
+
const b = Number(chosen[2] ?? chosen[1]);
|
|
293
|
+
const last = Math.max(a, b);
|
|
294
|
+
if (last < 1 || last > count) return undefined;
|
|
295
|
+
return [Math.max(1, Math.min(a, b)) - 1, last - 1];
|
|
251
296
|
}
|
|
252
297
|
|
|
253
298
|
/**
|
|
@@ -345,3 +390,48 @@ export function planChecklist(plan: string, runs: readonly AgentRun[]): PlanStep
|
|
|
345
390
|
checklistMemo = { plan, runs, steps };
|
|
346
391
|
return steps;
|
|
347
392
|
}
|
|
393
|
+
|
|
394
|
+
/* -------------------------------------------------------------------------
|
|
395
|
+
* Step work: how long the workers have worked on each step, and which steps
|
|
396
|
+
* they are on right now, for the checklist's `active · 1m 12s` marks.
|
|
397
|
+
* ---------------------------------------------------------------------- */
|
|
398
|
+
|
|
399
|
+
export interface StepWork {
|
|
400
|
+
/** Worker time on the step so far, in ms (the running runs up to `now`). */
|
|
401
|
+
ms: number;
|
|
402
|
+
/** A worker is on it right now. */
|
|
403
|
+
active: boolean;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** The steps a run works on: every step of a range it names, else the step it targets, else the next one still open. */
|
|
407
|
+
function runSteps(steps: readonly string[], instruction: string | undefined, completed: ReadonlySet<number>): number[] {
|
|
408
|
+
const range = instruction ? explicitStepRange(instruction, steps.length) : undefined;
|
|
409
|
+
if (range) return Array.from({ length: range[1] - range[0] + 1 }, (_value, offset) => range[0] + offset);
|
|
410
|
+
const target = targetStep(steps, instruction, completed);
|
|
411
|
+
const step = target >= 0 ? target : nextOpenStep(steps, completed);
|
|
412
|
+
return step >= 0 ? [step] : [];
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Each step's worker time and whether a worker is on it now, replaying the
|
|
417
|
+
* runs in start order as the checklist does. A run counts in full toward each
|
|
418
|
+
* step it works on (a `Steps 2-4` run toward all three), and its success
|
|
419
|
+
* completes them, so a later run that names no step goes to the next one.
|
|
420
|
+
*/
|
|
421
|
+
export function stepWork(steps: readonly string[], runs: readonly AgentRun[], now = Date.now()): StepWork[] {
|
|
422
|
+
const work = steps.map(() => ({ ms: 0, active: false }));
|
|
423
|
+
const completed = new Set<number>();
|
|
424
|
+
for (const run of byStart(runs)) {
|
|
425
|
+
if (run.role !== "worker") continue;
|
|
426
|
+
const covered = runSteps(steps, run.instruction, completed);
|
|
427
|
+
const start = Date.parse(run.startedAt);
|
|
428
|
+
const end = run.finishedAt ? Date.parse(run.finishedAt) : now;
|
|
429
|
+
const ms = Number.isFinite(start) && Number.isFinite(end) ? Math.max(0, end - start) : 0;
|
|
430
|
+
for (const index of covered) {
|
|
431
|
+
work[index]!.ms += ms;
|
|
432
|
+
if (run.status === "running") work[index]!.active = true;
|
|
433
|
+
}
|
|
434
|
+
if (run.status === "success" && covered.length > 0) markThrough(completed, covered[covered.length - 1]!);
|
|
435
|
+
}
|
|
436
|
+
return work;
|
|
437
|
+
}
|
package/src/pi/route.ts
CHANGED
|
@@ -14,7 +14,6 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
|
|
|
14
14
|
import { Container, Text } from "@earendil-works/pi-tui";
|
|
15
15
|
import { Type } from "typebox";
|
|
16
16
|
import type { Domain } from "../schemas/agent.ts";
|
|
17
|
-
import type { TaskTriage } from "../schemas/task.ts";
|
|
18
17
|
import { chooseRoute, type RequestRoute } from "../workflow/track.ts";
|
|
19
18
|
import { triageFor } from "../classifier/instance.ts";
|
|
20
19
|
import { ownedTask } from "../state/persistence.ts";
|
package/src/pi/run-summary.ts
CHANGED
|
@@ -124,60 +124,3 @@ export function runFromLog(entry: RunLogEntry, taskId: string): AgentRun {
|
|
|
124
124
|
...(entry.error ? { error: entry.error } : {}),
|
|
125
125
|
};
|
|
126
126
|
}
|
|
127
|
-
|
|
128
|
-
/** Silence after which a working agent reads as "quiet" in the scene. */
|
|
129
|
-
export const QUIET_MS = 45_000;
|
|
130
|
-
|
|
131
|
-
/** How long a running agent has produced nothing, or 0 when it is not running or has no clock yet. */
|
|
132
|
-
export function quietFor(run: Pick<AgentRun, "status" | "lastEventAt" | "startedAt">, now: number): number {
|
|
133
|
-
if (run.status !== "running") return 0;
|
|
134
|
-
const last = run.lastEventAt ?? Date.parse(run.startedAt);
|
|
135
|
-
const quiet = now - last;
|
|
136
|
-
return Number.isFinite(quiet) && quiet > 0 ? quiet : 0;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
export interface FeedLine {
|
|
140
|
-
text: string;
|
|
141
|
-
kind: "info" | "warning";
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/** Ticks one working agent holds the feed before it rotates to the next (~3 s at the live clock). */
|
|
145
|
-
export const FEED_ROTATE_TICKS = 12;
|
|
146
|
-
|
|
147
|
-
/** What one working agent is doing right now, with a warning when something needs attention. */
|
|
148
|
-
function liveFeed(run: AgentRun, now: number): FeedLine {
|
|
149
|
-
const name = agentName(run);
|
|
150
|
-
if (run.waitingFor) return { text: `⧗ ${name} waiting for ${run.waitingFor}${run.note ? ` · ${run.note}` : ""}`, kind: "warning" };
|
|
151
|
-
const quiet = quietFor(run, now);
|
|
152
|
-
if (quiet >= QUIET_MS) {
|
|
153
|
-
const doing = run.activity ? ` (last: ${run.activity}${run.detail ? ` ${run.detail}` : ""})` : "";
|
|
154
|
-
return { text: `! ${name} quiet for ${shortDuration(quiet)}${doing}`, kind: "warning" };
|
|
155
|
-
}
|
|
156
|
-
if (run.note && run.noteKind === "warning") return { text: `! ${name} ${run.note}`, kind: "warning" };
|
|
157
|
-
const parts = [`▸ ${name} ${run.activity ?? "working"}${run.detail ? ` ${run.detail}` : ""}`];
|
|
158
|
-
const turns = run.turns ?? run.usage?.turns;
|
|
159
|
-
if (turns) parts.push(`turn ${turns}`);
|
|
160
|
-
if (run.tools) parts.push(`${run.tools} tools`);
|
|
161
|
-
if (run.usage && run.usage.input + run.usage.output > 0) parts.push(`${tokens(run.usage.input + run.usage.output)} tok`);
|
|
162
|
-
if (run.note) parts.push(run.note);
|
|
163
|
-
return { text: parts.join(" · "), kind: "info" };
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/**
|
|
167
|
-
* The scene's feed row: a working agent that needs attention first, otherwise
|
|
168
|
-
* the working agents in rotation, otherwise the most recent finished run.
|
|
169
|
-
* Undefined before anything has run.
|
|
170
|
-
*/
|
|
171
|
-
export function feedLine(runs: readonly AgentRun[], now: number, tick: number): FeedLine | undefined {
|
|
172
|
-
const running = runs.filter((run) => run.status === "running");
|
|
173
|
-
if (running.length > 0) {
|
|
174
|
-
const lines = running.map((run) => liveFeed(run, now));
|
|
175
|
-
const warning = lines.find((line) => line.kind === "warning");
|
|
176
|
-
if (warning) return warning;
|
|
177
|
-
const index = Math.floor(Math.max(0, tick) / FEED_ROTATE_TICKS) % lines.length;
|
|
178
|
-
return lines[index];
|
|
179
|
-
}
|
|
180
|
-
const last = [...runs].filter((run) => run.finishedAt).sort((a, b) => Date.parse(a.finishedAt!) - Date.parse(b.finishedAt!)).at(-1);
|
|
181
|
-
if (!last) return undefined;
|
|
182
|
-
return { text: describeRun(last, now), kind: last.status === "success" && !last.wrappedUp ? "info" : "warning" };
|
|
183
|
-
}
|
package/src/schemas/agent.ts
CHANGED
|
@@ -33,7 +33,3 @@ export interface DomainSpec {
|
|
|
33
33
|
export function isDomain(value: string): value is Domain {
|
|
34
34
|
return (DOMAINS as readonly string[]).includes(value);
|
|
35
35
|
}
|
|
36
|
-
|
|
37
|
-
export function isRole(value: string): value is Role {
|
|
38
|
-
return (ROLES as readonly string[]).includes(value);
|
|
39
|
-
}
|
|
@@ -13,8 +13,6 @@ export type ThinkingLevelName = (typeof THINKING_LEVELS)[number];
|
|
|
13
13
|
*/
|
|
14
14
|
const INHERIT = "inherit";
|
|
15
15
|
export const INHERIT_MODEL = INHERIT;
|
|
16
|
-
/** Legacy thinking sentinel; configs that still carry it migrate to `DEFAULT_THINKING`. */
|
|
17
|
-
export const INHERIT_THINKING = INHERIT;
|
|
18
16
|
/** Level used for a missing, legacy `inherit` or unknown thinking value. */
|
|
19
17
|
export const DEFAULT_THINKING: ThinkingLevelName = "medium";
|
|
20
18
|
/** Scouts are reconnaissance: always fast, never configurable. */
|
package/src/state/budget.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
11
11
|
import { dirname, join } from "node:path";
|
|
12
12
|
import { taskDirFor } from "./persistence.ts";
|
|
13
|
-
import {
|
|
13
|
+
import { clearStaleRuns, updateWork } from "./work-time.ts";
|
|
14
14
|
|
|
15
15
|
/** What one delegation was given. */
|
|
16
16
|
export interface Allotment {
|
|
@@ -74,18 +74,6 @@ function writeBudget(root: string, configDir: string, taskId: string, budget: Ta
|
|
|
74
74
|
writeFileSync(path, `${JSON.stringify({ ...budget, allotments: budget.allotments.slice(-MAX_ALLOTMENTS) }, null, 2)}\n`, "utf8");
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
/** A task's budget as a display reads it every frame: parsed again only when the file changes. */
|
|
78
|
-
export function peekBudget(root: string, configDir: string, taskId: string): TaskBudget | undefined {
|
|
79
|
-
const value = readJsonCached(budgetPath(root, configDir, taskId), isBudget);
|
|
80
|
-
return value ? { minutes: value.minutes, granted: value.granted ?? 0, usedMs: value.usedMs, allotments: Array.isArray(value.allotments) ? value.allotments : [] } : undefined;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** Work time spent of the whole, for the lobby's status box; undefined without a budget. */
|
|
84
|
-
export function budgetClock(root: string, configDir: string, taskId: string, now = Date.now()): { usedMs: number; totalMs: number } | undefined {
|
|
85
|
-
const budget = peekBudget(root, configDir, taskId);
|
|
86
|
-
return budget ? { usedMs: usedMs(taskId, budget, now), totalMs: (budget.minutes + budget.granted) * 60_000 } : undefined;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
77
|
/** Change a task's budget in place (read afresh, written back); undefined when it has none. */
|
|
90
78
|
export function updateBudget(root: string, configDir: string, taskId: string, change: (budget: TaskBudget) => void): TaskBudget | undefined {
|
|
91
79
|
const budget = readBudget(root, configDir, taskId);
|
|
@@ -127,6 +115,11 @@ export function formatMinutes(ms: number): string {
|
|
|
127
115
|
}
|
|
128
116
|
|
|
129
117
|
/* ------------------------------------------------------------ work clock */
|
|
118
|
+
/*
|
|
119
|
+
* Every task the session drives has a work clock, budget or not: it keeps the
|
|
120
|
+
* task's work time (work.json, what the lobby shows as "worked") and, when the
|
|
121
|
+
* task has a budget, the time spent of it.
|
|
122
|
+
*/
|
|
130
123
|
|
|
131
124
|
interface Clock {
|
|
132
125
|
root: string;
|
|
@@ -141,41 +134,74 @@ interface Clock {
|
|
|
141
134
|
|
|
142
135
|
/** Work clocks of the tasks this process drives. */
|
|
143
136
|
const clocks = new Map<string, Clock>();
|
|
144
|
-
/** How often a
|
|
137
|
+
/** How often a running clock is saved, so other windows see time pass (and see it is still kept). */
|
|
145
138
|
export const CLOCK_FLUSH_MS = 30_000;
|
|
146
139
|
let flusher: ReturnType<typeof setInterval> | undefined;
|
|
140
|
+
/** The oracle's turn is running in this process. */
|
|
141
|
+
let turn = false;
|
|
147
142
|
|
|
148
|
-
/** Fold the stretch that counted into the saved
|
|
143
|
+
/** Fold the stretch that counted into the saved totals, and start a new one if it still counts. */
|
|
149
144
|
function settle(taskId: string, clock: Clock, now = Date.now()): void {
|
|
145
|
+
let spent = 0;
|
|
150
146
|
if (clock.since !== undefined) {
|
|
151
|
-
|
|
147
|
+
spent = Math.max(0, now - clock.since);
|
|
152
148
|
clock.since = undefined;
|
|
153
149
|
if (spent > 0) updateBudget(clock.root, clock.configDir, taskId, (budget) => { budget.usedMs += spent; });
|
|
154
150
|
}
|
|
155
151
|
if (clock.running && clock.waits === 0) clock.since = now;
|
|
152
|
+
const since = clock.since;
|
|
153
|
+
updateWork(clock.root, clock.configDir, taskId, (work) => {
|
|
154
|
+
work.workedMs += spent;
|
|
155
|
+
if (since !== undefined) work.runningSince = new Date(since).toISOString();
|
|
156
|
+
else delete work.runningSince;
|
|
157
|
+
}, new Date(now));
|
|
156
158
|
}
|
|
157
159
|
|
|
158
160
|
function ensureFlusher(): void {
|
|
159
161
|
if (flusher) return;
|
|
160
162
|
flusher = setInterval(() => {
|
|
161
|
-
for (const [taskId, clock] of clocks) if (clock.
|
|
163
|
+
for (const [taskId, clock] of clocks) if (clock.running) settle(taskId, clock);
|
|
162
164
|
}, CLOCK_FLUSH_MS);
|
|
163
165
|
flusher.unref();
|
|
164
166
|
}
|
|
165
167
|
|
|
166
|
-
/** The oracle
|
|
168
|
+
/** The oracle's turn started: a task it drives, or starts within the turn, is being worked on. */
|
|
169
|
+
export function turnStarted(): void {
|
|
170
|
+
turn = true;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The oracle started a turn on a task (or took one up within it): its clock runs. */
|
|
167
174
|
export function startClock(root: string, configDir: string, taskId: string): void {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
175
|
+
let clock = clocks.get(taskId);
|
|
176
|
+
if (!clock) {
|
|
177
|
+
clock = { root, configDir, running: false, waits: 0 };
|
|
178
|
+
clocks.set(taskId, clock);
|
|
179
|
+
clearStaleRuns(root, configDir, taskId);
|
|
180
|
+
}
|
|
181
|
+
if (clock.running) return;
|
|
171
182
|
clock.running = true;
|
|
172
183
|
settle(taskId, clock);
|
|
173
184
|
ensureFlusher();
|
|
174
185
|
}
|
|
175
186
|
|
|
176
|
-
/**
|
|
187
|
+
/**
|
|
188
|
+
* The task the session drives now, after a step that may have started, ended or taken over one: while
|
|
189
|
+
* a turn runs, its clock runs and any other task's stops.
|
|
190
|
+
*/
|
|
191
|
+
export function clockTask(root: string, configDir: string, taskId: string | undefined): void {
|
|
192
|
+
for (const [id, clock] of clocks) {
|
|
193
|
+
if (id === taskId || !clock.running) continue;
|
|
194
|
+
clock.running = false;
|
|
195
|
+
settle(id, clock);
|
|
196
|
+
}
|
|
197
|
+
if (taskId && turn) startClock(root, configDir, taskId);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** The oracle's turn ended: the clocks stop until the next one. */
|
|
177
201
|
export function stopClocks(): void {
|
|
202
|
+
turn = false;
|
|
178
203
|
for (const [taskId, clock] of clocks) {
|
|
204
|
+
if (!clock.running && clock.since === undefined) continue;
|
|
179
205
|
clock.running = false;
|
|
180
206
|
settle(taskId, clock);
|
|
181
207
|
}
|
|
@@ -215,6 +241,7 @@ export function usedMs(taskId: string, budget: TaskBudget, now = Date.now()): nu
|
|
|
215
241
|
/** Forget the clocks (tests; a session that ends). */
|
|
216
242
|
export function resetClocks(): void {
|
|
217
243
|
clocks.clear();
|
|
244
|
+
turn = false;
|
|
218
245
|
if (flusher) clearInterval(flusher);
|
|
219
246
|
flusher = undefined;
|
|
220
247
|
}
|
package/src/state/metrics.ts
CHANGED
|
@@ -356,7 +356,6 @@ export function aggregateMetrics(records: readonly MetricRecord[], by: GroupBy =
|
|
|
356
356
|
}
|
|
357
357
|
|
|
358
358
|
export type SortKey = "runs" | "avg" | "success" | "cost";
|
|
359
|
-
export const SORT_KEYS: readonly SortKey[] = ["runs", "avg", "success", "cost"];
|
|
360
359
|
|
|
361
360
|
export function sortGroups(groups: readonly MetricGroup[], key: SortKey): MetricGroup[] {
|
|
362
361
|
const score = (group: MetricGroup): number => {
|
package/src/state/persistence.ts
CHANGED
|
@@ -270,8 +270,3 @@ export function removeTaskScratchpads(root: string, configDir: string, taskId: s
|
|
|
270
270
|
if (file.startsWith("scout-")) rmSync(join(dir, file), { force: true });
|
|
271
271
|
}
|
|
272
272
|
}
|
|
273
|
-
|
|
274
|
-
/** Delete the temporary task dir after knowledge has been distilled. */
|
|
275
|
-
export function cleanupTaskDir(root: string, configDir: string, taskId: string): void {
|
|
276
|
-
rmSync(taskDir(dataRoot(root, configDir), taskId), { recursive: true, force: true });
|
|
277
|
-
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How long a task's agents have actually worked on it, and which worker runs
|
|
3
|
+
* are under way. Work time is the oracle's turns on the task — which include
|
|
4
|
+
* every agent it runs — and not the time it waits on the user (between turns,
|
|
5
|
+
* and while a dialog is open); the work clock in budget.ts measures it. The
|
|
6
|
+
* session that owns the task keeps it beside the task's state as work.json,
|
|
7
|
+
* refreshed while it works, so every window can show the time ticking and
|
|
8
|
+
* mark the steps being worked on. A file its owner stopped refreshing (the
|
|
9
|
+
* session ended or crashed) reads as stopped.
|
|
10
|
+
*/
|
|
11
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
import { taskDirFor } from "./persistence.ts";
|
|
14
|
+
|
|
15
|
+
/** A worker run under way: the step it works on is found from its instruction. */
|
|
16
|
+
export interface ActiveRun {
|
|
17
|
+
runId: string;
|
|
18
|
+
instruction: string;
|
|
19
|
+
startedAt: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface WorkTime {
|
|
23
|
+
/** Work time folded in so far, in ms. */
|
|
24
|
+
workedMs: number;
|
|
25
|
+
/** While the agents work: since when the current stretch counts. */
|
|
26
|
+
runningSince?: string;
|
|
27
|
+
active: ActiveRun[];
|
|
28
|
+
/** When the owning session last wrote it. */
|
|
29
|
+
updatedAt: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The clock as a reader sees it at one moment. */
|
|
33
|
+
export interface WorkProjection {
|
|
34
|
+
workedMs: number;
|
|
35
|
+
/** Still counting: the reader adds the time that passes. */
|
|
36
|
+
running: boolean;
|
|
37
|
+
active: ActiveRun[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A file older than this is no longer being kept (the owner refreshes it every 30s while it works). */
|
|
41
|
+
export const WORK_FRESH_MS = 75_000;
|
|
42
|
+
|
|
43
|
+
export function workPath(root: string, configDir: string, taskId: string): string {
|
|
44
|
+
return join(taskDirFor(root, configDir, taskId), "work.json");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isWork(value: unknown): value is WorkTime {
|
|
48
|
+
const work = value as Partial<WorkTime> | undefined;
|
|
49
|
+
return Boolean(work && typeof work.workedMs === "number" && typeof work.updatedAt === "string");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function readWork(root: string, configDir: string, taskId: string): WorkTime | undefined {
|
|
53
|
+
try {
|
|
54
|
+
const value: unknown = JSON.parse(readFileSync(workPath(root, configDir, taskId), "utf8"));
|
|
55
|
+
if (!isWork(value)) return undefined;
|
|
56
|
+
return { workedMs: value.workedMs, ...(value.runningSince ? { runningSince: value.runningSince } : {}), active: Array.isArray(value.active) ? value.active : [], updatedAt: value.updatedAt };
|
|
57
|
+
} catch {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Change a task's work time in place (read afresh, written back atomically), starting it when there is none. */
|
|
63
|
+
export function updateWork(root: string, configDir: string, taskId: string, change: (work: WorkTime) => void, now = new Date()): void {
|
|
64
|
+
const work = readWork(root, configDir, taskId) ?? { workedMs: 0, active: [], updatedAt: now.toISOString() };
|
|
65
|
+
change(work);
|
|
66
|
+
work.updatedAt = now.toISOString();
|
|
67
|
+
try {
|
|
68
|
+
const path = workPath(root, configDir, taskId);
|
|
69
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
70
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
71
|
+
writeFileSync(tmp, `${JSON.stringify(work, null, 2)}\n`, "utf8");
|
|
72
|
+
renameSync(tmp, path);
|
|
73
|
+
} catch {
|
|
74
|
+
// A read-only tree must never fail the workflow; the time just stops being saved.
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** What a reader shows now: a stale file counts up to its last refresh and nothing in it is running. */
|
|
79
|
+
export function projectWork(work: WorkTime | undefined, now = Date.now()): WorkProjection | undefined {
|
|
80
|
+
if (!work) return undefined;
|
|
81
|
+
const updated = Date.parse(work.updatedAt);
|
|
82
|
+
const fresh = Number.isFinite(updated) && now - updated <= WORK_FRESH_MS;
|
|
83
|
+
const since = work.runningSince ? Date.parse(work.runningSince) : Number.NaN;
|
|
84
|
+
const end = fresh ? now : updated;
|
|
85
|
+
const stretch = Number.isFinite(since) && Number.isFinite(end) ? Math.max(0, end - since) : 0;
|
|
86
|
+
return { workedMs: Math.max(0, work.workedMs) + stretch, running: fresh && Number.isFinite(since), active: fresh ? [...work.active] : [] };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A task's work time as a reader sees it now; undefined for a task with none (one from before it was kept). */
|
|
90
|
+
export function taskWork(root: string, configDir: string, taskId: string, now = Date.now()): WorkProjection | undefined {
|
|
91
|
+
return projectWork(readWork(root, configDir, taskId), now);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/* ------------------------------------------------------ worker runs */
|
|
95
|
+
|
|
96
|
+
/** Worker runs this process has written as under way, per task: the file changes only when a run starts or ends. */
|
|
97
|
+
const noted = new Map<string, Set<string>>();
|
|
98
|
+
|
|
99
|
+
/** A worker run of the task started or ended: the steps it works on read as active while it runs. */
|
|
100
|
+
export function noteWorkerRun(root: string, configDir: string, taskId: string, run: { runId: string; instruction?: string; startedAt: string; status: string }): void {
|
|
101
|
+
const running = run.status === "running";
|
|
102
|
+
const ids = noted.get(taskId) ?? new Set<string>();
|
|
103
|
+
if (running === ids.has(run.runId)) return;
|
|
104
|
+
if (running) ids.add(run.runId);
|
|
105
|
+
else ids.delete(run.runId);
|
|
106
|
+
noted.set(taskId, ids);
|
|
107
|
+
updateWork(root, configDir, taskId, (work) => {
|
|
108
|
+
work.active = work.active.filter((entry) => entry.runId !== run.runId);
|
|
109
|
+
if (running) work.active.push({ runId: run.runId, instruction: run.instruction ?? "", startedAt: run.startedAt });
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** This process starts keeping a task's time: runs a crashed session left marked as under way are not. */
|
|
114
|
+
export function clearStaleRuns(root: string, configDir: string, taskId: string): void {
|
|
115
|
+
if ((noted.get(taskId)?.size ?? 0) > 0) return;
|
|
116
|
+
const work = readWork(root, configDir, taskId);
|
|
117
|
+
if (work && work.active.length > 0) updateWork(root, configDir, taskId, (next) => { next.active = []; });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Forget the runs noted (tests). */
|
|
121
|
+
export function resetNotedRuns(): void {
|
|
122
|
+
noted.clear();
|
|
123
|
+
}
|
package/src/webui/api/index.ts
CHANGED
|
@@ -373,11 +373,6 @@ function buildRoutes(): Record<string, Route> {
|
|
|
373
373
|
|
|
374
374
|
const ROUTES = buildRoutes();
|
|
375
375
|
|
|
376
|
-
/** Whether the router knows a call (defined names only; never inherited ones). */
|
|
377
|
-
export function isKnownCall(name: string): boolean {
|
|
378
|
-
return Object.hasOwn(ROUTES, name);
|
|
379
|
-
}
|
|
380
|
-
|
|
381
376
|
/**
|
|
382
377
|
* Run `POST /api/<name>` with an already-parsed body. Throws `HttpError` for
|
|
383
378
|
* refusals (unknown call, a body that is no object, a schema mismatch, or a
|
package/src/webui/api/lobby.ts
CHANGED
|
@@ -9,23 +9,30 @@ import type { Task } from "../../schemas/task.ts";
|
|
|
9
9
|
import type { ApiContext } from "./index.ts";
|
|
10
10
|
import { withAttachments } from "../uploads.ts";
|
|
11
11
|
import { fail } from "./index.ts";
|
|
12
|
-
import {
|
|
12
|
+
import { stepViews } from "./plan-facts.ts";
|
|
13
|
+
import { TERMINAL_STATES } from "../../schemas/task.ts";
|
|
14
|
+
import type { WorkProjection } from "../../state/work-time.ts";
|
|
13
15
|
import type { LobbySnapshot, SnapshotTask } from "../protocol.ts";
|
|
14
16
|
|
|
15
|
-
/** Plan facts the header shows: step progress
|
|
16
|
-
function planFacts(task: Task): Pick<SnapshotTask, "progress" | "currentStep"> {
|
|
17
|
-
const
|
|
18
|
-
|
|
17
|
+
/** Plan facts the header shows: step progress, the steps a worker is on (with their time) or else the next one, and the work time. */
|
|
18
|
+
function planFacts(task: Task, work: WorkProjection | undefined): Pick<SnapshotTask, "progress" | "currentStep" | "activeSteps" | "work"> {
|
|
19
|
+
const clock = work ? { work: { workedMs: Math.round(work.workedMs), running: work.running && !TERMINAL_STATES.includes(task.state) } } : {};
|
|
20
|
+
const steps = stepViews(task, work);
|
|
21
|
+
if (steps.length === 0) return clock;
|
|
22
|
+
const active = steps.filter((step) => step.active);
|
|
19
23
|
const current = steps.find((step) => step.status === "current");
|
|
20
24
|
return {
|
|
21
25
|
progress: { done: steps.filter((step) => step.status === "done").length, total: steps.length },
|
|
22
26
|
...(current ? { currentStep: current.text } : {}),
|
|
27
|
+
...(active.length > 0 ? { activeSteps: active.map((step) => ({ text: step.text, workedMs: step.workedMs })) } : {}),
|
|
28
|
+
...clock,
|
|
23
29
|
};
|
|
24
30
|
}
|
|
25
31
|
|
|
26
32
|
function taskOf(ctx: ApiContext): LobbySnapshot["task"] {
|
|
27
33
|
const task = ctx.service.zen().task;
|
|
28
34
|
if (!task) return undefined;
|
|
35
|
+
const work = ctx.service.work?.(task.id);
|
|
29
36
|
return {
|
|
30
37
|
id: task.id,
|
|
31
38
|
title: task.title,
|
|
@@ -33,7 +40,7 @@ function taskOf(ctx: ApiContext): LobbySnapshot["task"] {
|
|
|
33
40
|
...(task.track ? { track: { path: task.track.path, size: task.track.size } } : {}),
|
|
34
41
|
domains: [...(task.domains ?? [])],
|
|
35
42
|
...(task.git ? { git: { branch: task.git.branch, ...(task.git.from ? { from: task.git.from } : {}) } } : {}),
|
|
36
|
-
...planFacts(task),
|
|
43
|
+
...planFacts(task, work),
|
|
37
44
|
};
|
|
38
45
|
}
|
|
39
46
|
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* What the Lobby header and the Tasks calls both read of a task's plan: the
|
|
3
|
-
* worker records as runs,
|
|
4
|
-
* `persistedRuns`) so the
|
|
3
|
+
* worker records as runs, the plan's checklist, the steps being worked on
|
|
4
|
+
* right now and the work time. Kept here (not via `persistedRuns`) so the
|
|
5
|
+
* server never loads the TUI.
|
|
5
6
|
*/
|
|
6
|
-
import { planChecklist, type PlanStep } from "../../pi/plan-checklist.ts";
|
|
7
|
+
import { planChecklist, planSteps, stepWork, type PlanStep } from "../../pi/plan-checklist.ts";
|
|
7
8
|
import type { AgentRun } from "../../schemas/findings.ts";
|
|
8
9
|
import type { Task } from "../../schemas/task.ts";
|
|
10
|
+
import type { WorkProjection } from "../../state/work-time.ts";
|
|
9
11
|
|
|
10
12
|
/** Worker records as runs, the same projection the terminal checklist reads. */
|
|
11
13
|
export function persistedTaskRuns(task: Task): AgentRun[] {
|
|
@@ -23,7 +25,35 @@ export function persistedTaskRuns(task: Task): AgentRun[] {
|
|
|
23
25
|
}));
|
|
24
26
|
}
|
|
25
27
|
|
|
26
|
-
/** The
|
|
27
|
-
|
|
28
|
-
|
|
28
|
+
/** The worker runs under way (from the owner's work file) that have not finished yet, as running runs. */
|
|
29
|
+
function activeRuns(task: Task, work: WorkProjection | undefined): AgentRun[] {
|
|
30
|
+
const finished = new Set((task.workerRuns ?? []).map((record) => record.runId));
|
|
31
|
+
return (work?.active ?? [])
|
|
32
|
+
.filter((run) => !finished.has(run.runId))
|
|
33
|
+
.map((run) => ({ runId: run.runId, taskId: task.id, domain: "backend" as const, role: "worker" as const, status: "running" as const, instruction: run.instruction, output: "", attempts: 1, startedAt: run.startedAt }));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** One step as the lobby shows it: its state, and while a worker is on it, how long they have worked on it. */
|
|
37
|
+
export interface StepView {
|
|
38
|
+
text: string;
|
|
39
|
+
status: PlanStep["status"];
|
|
40
|
+
active: boolean;
|
|
41
|
+
/** Worker time on the step, in ms. */
|
|
42
|
+
workedMs: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The plan's steps with their state, which ones a worker is on right now and
|
|
47
|
+
* the worker time on each. A step being worked on is never done, so steps run
|
|
48
|
+
* side by side all read active.
|
|
49
|
+
*/
|
|
50
|
+
export function stepViews(task: Task, work: WorkProjection | undefined, now = Date.now()): StepView[] {
|
|
51
|
+
if (!task.plan) return [];
|
|
52
|
+
const runs = [...persistedTaskRuns(task), ...activeRuns(task, work)];
|
|
53
|
+
const checklist = planChecklist(task.plan, runs);
|
|
54
|
+
const times = stepWork(planSteps(task.plan), runs, now);
|
|
55
|
+
return checklist.map((step, index) => {
|
|
56
|
+
const time = times[index] ?? { ms: 0, active: false };
|
|
57
|
+
return { text: step.text, status: time.active ? "current" : step.status, active: time.active, workedMs: Math.round(time.ms) };
|
|
58
|
+
});
|
|
29
59
|
}
|