@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.
Files changed (42) hide show
  1. package/README.md +3 -1
  2. package/package.json +1 -5
  3. package/prompts/master.md +12 -7
  4. package/prompts/planner.md +5 -2
  5. package/src/classifier/hosts.ts +0 -11
  6. package/src/classifier/instance.ts +0 -5
  7. package/src/execution/agent-runner.ts +0 -18
  8. package/src/knowledge/compactor.ts +0 -70
  9. package/src/lobby/ask.ts +0 -15
  10. package/src/lobby/host.ts +2 -0
  11. package/src/lobby/issues.ts +0 -8
  12. package/src/lobby/pulls.ts +1 -44
  13. package/src/lobby/runtime.ts +1 -1
  14. package/src/lobby/service.ts +3 -1
  15. package/src/lobby/session-files.ts +1 -45
  16. package/src/lobby/split.ts +33 -1
  17. package/src/lobby/task-rows.ts +2 -0
  18. package/src/master/research.ts +0 -16
  19. package/src/pi/events.ts +9 -3
  20. package/src/pi/model-settings.ts +0 -1
  21. package/src/pi/plan-checklist.ts +98 -8
  22. package/src/pi/route.ts +0 -1
  23. package/src/pi/run-summary.ts +0 -57
  24. package/src/schemas/agent.ts +0 -4
  25. package/src/schemas/configuration.ts +0 -2
  26. package/src/state/budget.ts +49 -22
  27. package/src/state/metrics.ts +0 -1
  28. package/src/state/persistence.ts +0 -5
  29. package/src/state/work-time.ts +123 -0
  30. package/src/webui/api/index.ts +0 -5
  31. package/src/webui/api/lobby.ts +13 -6
  32. package/src/webui/api/plan-facts.ts +36 -6
  33. package/src/webui/api/tasks.ts +27 -6
  34. package/src/webui/dev/fake-service.ts +6 -0
  35. package/src/webui/dev/fixtures.ts +2 -0
  36. package/src/webui/protocol.ts +20 -3
  37. package/src/workflow/workflow.ts +35 -8
  38. package/webui/dist/assets/index-C1IwecNS.css +2 -0
  39. package/webui/dist/assets/{index-BRVbs5In.js → index-C4Ww36TW.js} +38 -38
  40. package/webui/dist/build.json +25 -37
  41. package/webui/dist/index.html +2 -2
  42. package/webui/dist/assets/index-BeRXVi_r.css +0 -2
@@ -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. `Step N` headings win; then a
104
- * `sequence|steps|order` section; then numbered lines; when none exists,
105
- * top-level bullets are the last resort, so unrelated bullet lists under other
106
- * headers never leak into the checklist. Only the shallowest items count.
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 -1;
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 -1;
249
- const last = Math.max(Number(chosen[1]), Number(chosen[2] ?? chosen[1]));
250
- return last >= 1 && last <= count ? last - 1 : -1;
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";
@@ -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
- }
@@ -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. */
@@ -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 { readJsonCached } from "./file-cache.ts";
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 counting clock is saved, so other windows see time pass. */
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 total, and start a new one if it still counts. */
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
- const spent = Math.max(0, now - clock.since);
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.since !== undefined) settle(taskId, 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 started a turn on a task: its clock runs, if the task has a budget. */
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
- if (!readBudget(root, configDir, taskId)) return;
169
- const clock = clocks.get(taskId) ?? { root, configDir, running: false, waits: 0 };
170
- clocks.set(taskId, clock);
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
- /** The oracle's turn ended: the clock stops until the next one. */
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
  }
@@ -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 => {
@@ -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
+ }
@@ -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
@@ -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 { taskSteps } from "./plan-facts.ts";
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 plus the step under way, if any. */
16
- function planFacts(task: Task): Pick<SnapshotTask, "progress" | "currentStep"> {
17
- const steps = taskSteps(task);
18
- if (steps.length === 0) return {};
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, and the plan's checklist. Kept here (not via
4
- * `persistedRuns`) so the server never loads the TUI.
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 plan's steps with their state; none when the task has no plan. */
27
- export function taskSteps(task: Task): PlanStep[] {
28
- return task.plan ? planChecklist(task.plan, persistedTaskRuns(task)) : [];
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
  }