@a-t-h-i/bot-lobby 0.6.25 → 0.6.27

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 (54) hide show
  1. package/README.md +10 -2
  2. package/package.json +1 -5
  3. package/prompts/master.md +36 -10
  4. package/prompts/planner.md +5 -2
  5. package/prompts/qa.md +139 -77
  6. package/prompts/reviewer.md +4 -0
  7. package/src/classifier/hosts.ts +0 -11
  8. package/src/classifier/instance.ts +0 -5
  9. package/src/classifier/qa-risk.ts +537 -0
  10. package/src/execution/agent-runner.ts +0 -18
  11. package/src/knowledge/compactor.ts +0 -70
  12. package/src/lobby/ask.ts +0 -15
  13. package/src/lobby/host.ts +2 -0
  14. package/src/lobby/issues.ts +0 -8
  15. package/src/lobby/planner.ts +14 -0
  16. package/src/lobby/pulls.ts +1 -44
  17. package/src/lobby/runtime.ts +1 -1
  18. package/src/lobby/service.ts +3 -1
  19. package/src/lobby/session-files.ts +1 -45
  20. package/src/lobby/split.ts +33 -1
  21. package/src/lobby/task-rows.ts +2 -0
  22. package/src/master/decisions.ts +5 -2
  23. package/src/master/master.ts +6 -0
  24. package/src/master/research.ts +0 -16
  25. package/src/pi/events.ts +13 -6
  26. package/src/pi/model-settings.ts +0 -1
  27. package/src/pi/plan-checklist.ts +98 -8
  28. package/src/pi/route.ts +0 -1
  29. package/src/pi/run-summary.ts +0 -57
  30. package/src/pi/start-task.ts +31 -4
  31. package/src/pi/tools.ts +3 -0
  32. package/src/schemas/agent.ts +0 -4
  33. package/src/schemas/configuration.ts +2 -4
  34. package/src/schemas/task.ts +32 -0
  35. package/src/state/backlog.ts +19 -3
  36. package/src/state/budget.ts +49 -22
  37. package/src/state/metrics.ts +0 -1
  38. package/src/state/persistence.ts +0 -5
  39. package/src/state/work-time.ts +123 -0
  40. package/src/webui/api/index.ts +0 -5
  41. package/src/webui/api/lobby.ts +13 -6
  42. package/src/webui/api/plan-facts.ts +36 -6
  43. package/src/webui/api/tasks.ts +28 -6
  44. package/src/webui/dev/fake-service.ts +6 -0
  45. package/src/webui/dev/fixtures.ts +2 -0
  46. package/src/webui/protocol.ts +23 -3
  47. package/src/workflow/track.ts +46 -7
  48. package/src/workflow/workflow.ts +231 -42
  49. package/webui/dist/assets/index-BUzOUrih.css +2 -0
  50. package/webui/dist/assets/index-myC4UiEg.js +108 -0
  51. package/webui/dist/build.json +37 -41
  52. package/webui/dist/index.html +2 -2
  53. package/webui/dist/assets/index-BRVbs5In.js +0 -105
  54. package/webui/dist/assets/index-BeRXVi_r.css +0 -2
@@ -7,7 +7,8 @@ import { existsSync } from "node:fs";
7
7
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
8
8
  import { createTask, taskRequest, type Task, type TaskTrack, type TaskTriage, type TrackPath } from "../schemas/task.ts";
9
9
  import type { Domain } from "../schemas/agent.ts";
10
- import { chooseTrack, trackLine, trackSummary } from "../workflow/track.ts";
10
+ import { chooseTrack, qaRequired, rosterWords, trackLine, trackSummary } from "../workflow/track.ts";
11
+ import { startFromAgreedPlan } from "../workflow/workflow.ts";
11
12
  import { lobbyFeed } from "../lobby/feed.ts";
12
13
  import { createTaskDir, ensureProjectStructure, loadTask, nextTaskId, ownedTask, saveTask, taskDirFor } from "../state/persistence.ts";
13
14
  import { detectProjectRoot, loadConfig } from "../state/project.ts";
@@ -43,7 +44,7 @@ function fastSteps(track: TaskTrack): string[] {
43
44
  const steps = [
44
45
  ...(track.roster.includes("researcher") ? ["First summon the researcher for the outside facts it needs: orchestrate action=research with a domain and the question."] : []),
45
46
  `Delegate now with orchestrate action=implement: ${builders}; open each task with "Step 1:" (the engine keeps the plan).`,
46
- ...(track.roster.includes("qa") && building.length > 0 ? ["QA takes part (tests): once the change is in, give qa the tests as the last step (action=implement domain=qa), or run action=qa."] : []),
47
+ ...(track.roster.includes("qa") && building.length > 0 ? ["QA takes part: once the change is in, run orchestrate action=qa. Jev reads the change and sizes QA to its risk; one with nothing that runs passes on the engine's checks."] : []),
47
48
  "Check `git diff --stat` and the report, then orchestrate action=complete with a one-line summary.",
48
49
  ];
49
50
  return [
@@ -54,6 +55,25 @@ function fastSteps(track: TaskTrack): string[] {
54
55
  ].filter(Boolean);
55
56
  }
56
57
 
58
+ /** A task from an agreed plan: straight to building its steps; QA only when it took part in the panel. */
59
+ function plannedSteps(task: Task): string[] {
60
+ const qa = qaRequired(task);
61
+ const steps = [
62
+ 'Delegate the plan\'s steps now with orchestrate action=implement, opening each task with "Step N:" and briefing it in full from the plan. Give one domain its consecutive steps in one call, and run independent domains together with assignments.',
63
+ "After each report, check `git diff --stat` and the report, then delegate the next step.",
64
+ qa
65
+ ? "Once every step is done, run orchestrate action=qa (Jev sizes it to the change), then action=complete with a one-line summary."
66
+ : "Once every step is done, call orchestrate action=complete with a one-line summary. QA sat out the planning panel, so there is no QA gate.",
67
+ ];
68
+ const out = task.track?.satOut ?? [];
69
+ return [
70
+ `Planned task: the user agreed this plan in the planning panel (${task.approvedPlan}), and it is already the task's plan. The panel asked the questions and read the code, so there is no clarifying, scouting, proposal or plan to write: start building.`,
71
+ out.length > 0 ? `Only the members who sat on the panel work on it (${rosterWords(task.track?.roster ?? [])}). ${rosterWords(out)} sat out, so give ${out.length === 1 ? "it" : "them"} nothing; if a step truly needs one, ask the user with orchestrate action=track (the roster with it, and why).` : "",
72
+ ...steps.map((step, index) => `${index + 1}. ${step}`),
73
+ "If the code contradicts the plan, amend it with action=plan (the plan with the change made) and tell the user in one line, rather than starting over.",
74
+ ].filter(Boolean);
75
+ }
76
+
57
77
  export function kickoff(task: Task, budgetMinutes = 0, options: { fastTrack?: boolean; routed?: boolean } = {}): string {
58
78
  const track = task.track;
59
79
  const head = [
@@ -68,6 +88,7 @@ export function kickoff(task: Task, budgetMinutes = 0, options: { fastTrack?: bo
68
88
  "",
69
89
  ];
70
90
  if (track?.path === "fast") return [...head, ...fastSteps(track)].join("\n");
91
+ if (task.approvedPlan && task.plan && task.state === "planning") return [...head, ...plannedSteps(task)].join("\n");
71
92
  const fastAllowed = Boolean(track) && options.fastTrack !== false && track?.userChoice !== "full" && track?.source !== "plan";
72
93
  return [
73
94
  ...head,
@@ -105,6 +126,10 @@ async function giveWorkspace(ctx: ExtensionContext, root: string, configDir: str
105
126
  export interface StartOptions {
106
127
  /** The planned task (PLAN-…) this task starts from: the user agreed its plan, so its proposal needs no approval. */
107
128
  approvedPlan?: string;
129
+ /** That plan: it becomes the task's plan, and the task starts at delegating its steps. */
130
+ plan?: string;
131
+ /** The panel seats that took part in that plan; one that sat it out (QA) is not part of the task. */
132
+ panel?: readonly string[];
108
133
  /** Start in auto mode: the oracle drives it to completion without asking. */
109
134
  auto?: boolean;
110
135
  /** The task's short title (a planned task's own title); derived from the request otherwise. */
@@ -140,8 +165,10 @@ export async function startTask(pi: ExtensionAPI, ctx: ExtensionContext, configD
140
165
  if (triage) task.triage = triage;
141
166
  // How serious the request reads: who takes part, and whether it takes the fast track or the full workflow.
142
167
  const config = loadConfig();
143
- task.track = chooseTrack(request, triage, { fastTrack: config.workflow.fastTrack, ...(options.track ? { forced: options.track } : {}), ...(options.approvedPlan ? { approvedPlan: true } : {}) });
168
+ task.track = chooseTrack(request, triage, { fastTrack: config.workflow.fastTrack, ...(options.track ? { forced: options.track } : {}), ...(options.approvedPlan ? { approvedPlan: true } : {}), ...(options.panel ? { planPanel: options.panel } : {}) });
144
169
  lobbyFeed.log("LOBBY", trackLine(task.track), "info");
170
+ // An agreed plan is the task's plan: it goes straight to delegating its steps.
171
+ if (options.approvedPlan && options.plan) startFromAgreedPlan(task, options.plan, taskDirFor(root, configDir, task.id));
145
172
  await giveWorkspace(ctx, root, configDir, task, options.isolation ?? config.workflow.gitIsolation);
146
173
  saveTask(root, configDir, task);
147
174
  const budgetMinutes = options.budget ?? config.workflow.taskBudgetMinutes;
@@ -192,7 +219,7 @@ export async function startPlannedTask(pi: ExtensionAPI, ctx: ExtensionContext,
192
219
  if (!plan) return `no planned task ${planId}`;
193
220
  if (plan.status !== "pending") return `${planId} was already started${plan.startedTaskId ? ` as ${plan.startedTaskId}` : ""}`;
194
221
  const waiting = unfinishedBefore(root, configDir, plan);
195
- const task = await startTask(pi, ctx, configDir, plannedTaskRequest(plan), { approvedPlan: plan.id, title: plan.title, ...(options.auto ? { auto: true } : {}) });
222
+ const task = await startTask(pi, ctx, configDir, plannedTaskRequest(plan), { approvedPlan: plan.id, plan: plan.brief, ...(plan.panel ? { panel: plan.panel } : {}), title: plan.title, ...(options.auto ? { auto: true } : {}) });
196
223
  if (!task) return `this session already drives a task; finish or cancel it first`;
197
224
  markPlannedTaskStarted(root, configDir, plan.id, task.id);
198
225
  if (waiting.length > 0) {
package/src/pi/tools.ts CHANGED
@@ -16,6 +16,7 @@ import { isQuiet } from "./quiet.ts";
16
16
  import { checkThinking, createProfileResolver, modelRef, type ModelLookup } from "./model-support.ts";
17
17
  import { agentName, describeRun } from "./run-summary.ts";
18
18
  import { lintFeed } from "../workflow/lint.ts";
19
+ import { qaRiskLine } from "../classifier/qa-risk.ts";
19
20
  import { lobbyFeed } from "../lobby/feed.ts";
20
21
  import {
21
22
  ORCHESTRATE_ACTIONS,
@@ -140,6 +141,8 @@ export function workflowDeps(
140
141
  const line = lintFeed(taskId, report);
141
142
  lobbyFeed.log("LINT", line.text, line.kind);
142
143
  },
144
+ // Jev's QA risk read of each task's change, as the QA gate starts.
145
+ onQaRisk: (taskId, assessment) => lobbyFeed.log("CLASSIFIER", `${taskId} ${qaRiskLine(assessment)}`, "info"),
143
146
  };
144
147
  }
145
148
 
@@ -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. */
@@ -172,7 +170,7 @@ export interface LobbyConfig {
172
170
  }
173
171
 
174
172
  /** Decisions the classifier can make, each switched on or off on its own. */
175
- export const CLASSIFIER_FEATURES = ["seats", "answers", "files", "triage", "effort", "review", "knowledge"] as const;
173
+ export const CLASSIFIER_FEATURES = ["seats", "answers", "files", "triage", "effort", "review", "knowledge", "qa"] as const;
176
174
  export type ClassifierFeature = (typeof CLASSIFIER_FEATURES)[number];
177
175
 
178
176
  /** Hosts that serve Jev behind the same System One API; `auto` takes OpenCode's free Jev when pi holds an OpenCode key, else TypeSafe. */
@@ -293,7 +291,7 @@ export const DEFAULT_CONFIG: BotLobbyConfig = {
293
291
  model: "",
294
292
  baseUrl: "",
295
293
  timeoutMs: 4000,
296
- features: { seats: true, answers: true, files: true, triage: true, effort: true, review: true, knowledge: true },
294
+ features: { seats: true, answers: true, files: true, triage: true, effort: true, review: true, knowledge: true, qa: true },
297
295
  thresholds: {
298
296
  seatAt: 0.35,
299
297
  reseatReadyAt: 0.6,
@@ -55,6 +55,8 @@ export interface ReviewRecord {
55
55
  findings: { severity: Severity; text: string }[];
56
56
  requiredChanges: string[];
57
57
  createdAt: string;
58
+ /** The engine's deterministic checks decided the round: no QA agent ran (a change with nothing that runs). */
59
+ checksOnly?: boolean;
58
60
  }
59
61
  /**
60
62
  * One finished worker delegation, kept on the task so the plan checklist can be
@@ -118,6 +120,32 @@ export interface TaskTriage {
118
120
  at: string;
119
121
  }
120
122
 
123
+ /** How much verification a finished change warrants, lowest first. */
124
+ export const QA_RISKS = ["none", "low", "medium", "high"] as const;
125
+ export type QaRisk = (typeof QA_RISKS)[number];
126
+
127
+ /**
128
+ * What the change a task built warrants of QA, read from its diff once the
129
+ * implementation is done: the QA gate's verification budget. The test budget
130
+ * is a ceiling, never a quota.
131
+ */
132
+ export interface QaRiskAssessment {
133
+ risk: QaRisk;
134
+ /** Whether an LLM QA pass is needed at all: without one, the engine's deterministic checks decide. */
135
+ qaRequired: boolean;
136
+ /** New behavioural tests that may be worth writing, at most `max`. */
137
+ testBudget: { min: number; max: number };
138
+ /** What QA should verify first, as words. */
139
+ focusAreas: string[];
140
+ /** The evidence behind the level: what the diff touches, with paths. */
141
+ riskFactors: string[];
142
+ existingTestsLikelySufficient: boolean;
143
+ reasoning: string;
144
+ /** Jev's read of the diff, or the engine's rules alone when Jev is off, failed or had nothing to read. */
145
+ source: "classifier" | "rules";
146
+ at: string;
147
+ }
148
+
121
149
  /** How much process a task gets: the fast track for a small, clear, low-risk change, the full workflow for the rest. */
122
150
  export type TrackPath = "fast" | "full";
123
151
 
@@ -142,6 +170,8 @@ export interface TaskTrack {
142
170
  userChoice?: TrackPath;
143
171
  /** The engine keeps the plan (fast track): each delegation adds its step. */
144
172
  autoPlan?: boolean;
173
+ /** Members who sat out the planning panel that agreed the plan: they do not work on the task (no QA gate without QA) unless the user lets one join. */
174
+ satOut?: TrackMember[];
145
175
  at: string;
146
176
  }
147
177
 
@@ -227,6 +257,8 @@ export interface Task {
227
257
  git?: TaskGit;
228
258
  /** The lint gate's last report on the files this task's agents touched. */
229
259
  lint?: LintReport;
260
+ /** The last read of what the built change warrants of QA, taken as the QA gate starts. */
261
+ qaRisk?: QaRiskAssessment;
230
262
  /** Set only by the user: the work is accepted with the lint errors it has, so the task may complete. */
231
263
  lintWaiver?: { at: string };
232
264
  }
@@ -11,6 +11,7 @@ import { writeFileEnsured } from "../knowledge/store.ts";
11
11
  import { forgetCachedUnder, readJsonCached } from "./file-cache.ts";
12
12
  import { dataRoot } from "./project.ts";
13
13
  import { taskSlug } from "./persistence.ts";
14
+ import type { PanelMember } from "../schemas/configuration.ts";
14
15
 
15
16
  export interface IssueRef {
16
17
  number: number;
@@ -44,6 +45,8 @@ export interface PlannedTask {
44
45
  startedTaskId?: string;
45
46
  /** Set when the plan was split into several tasks and this is one of them. */
46
47
  split?: SplitInfo;
48
+ /** The panel seats that took part in planning it (sat a round and were not unseated); absent on plans saved before this was kept. */
49
+ panel?: PanelMember[];
47
50
  }
48
51
 
49
52
  export function backlogDir(root: string, configDir: string): string {
@@ -63,7 +66,7 @@ function isPlannedTask(value: unknown): value is PlannedTask {
63
66
  export function savePlannedTask(
64
67
  root: string,
65
68
  configDir: string,
66
- input: { title: string; brief: string; issue?: IssueRef; split?: SplitInfo },
69
+ input: { title: string; brief: string; issue?: IssueRef; split?: SplitInfo; panel?: readonly PanelMember[] },
67
70
  now = new Date(),
68
71
  ): PlannedTask {
69
72
  const title = input.title.trim() || "planned task";
@@ -73,7 +76,7 @@ export function savePlannedTask(
73
76
  let id = base;
74
77
  for (let n = 2; existsSync(entryPath(root, configDir, id)); n++) id = `${base}-${n}`;
75
78
  const at = now.toISOString();
76
- const entry: PlannedTask = { id, title, brief, createdAt: at, updatedAt: at, status: "pending", ...(input.issue ? { issue: input.issue } : {}), ...(input.split ? { split: input.split } : {}) };
79
+ const entry: PlannedTask = { id, title, brief, createdAt: at, updatedAt: at, status: "pending", ...(input.issue ? { issue: input.issue } : {}), ...(input.split ? { split: input.split } : {}), ...(input.panel ? { panel: [...input.panel] } : {}) };
77
80
  writeFileEnsured(entryPath(root, configDir, id), JSON.stringify(entry, null, 2));
78
81
  return entry;
79
82
  }
@@ -131,9 +134,22 @@ export function splitLine(split: SplitInfo): string {
131
134
  return `This is part ${split.part} of ${split.of} of one plan that was split into separate tasks: ${others}.${before} Do only this part; the others are their own tasks.`;
132
135
  }
133
136
 
137
+ /** Where a planned task's request moves on from what it is to the plan itself. */
138
+ const AGREED_PLAN = "\n\nAgreed plan (from the planning session):\n";
139
+
134
140
  /** The request a started task carries: the agreed plan, plus the issue it came from, plus where it sits in a split plan. */
135
141
  export function plannedTaskRequest(entry: PlannedTask): string {
136
142
  const source = entry.issue ? `\n\nFrom GitHub issue #${entry.issue.number}: ${entry.issue.title}${entry.issue.url ? ` (${entry.issue.url})` : ""}` : "";
137
143
  const part = entry.split ? `\n\n${splitLine(entry.split)}` : "";
138
- return `${entry.title}${part}\n\nAgreed plan (from the planning session):\n${entry.brief}${source}`;
144
+ return `${entry.title}${part}${AGREED_PLAN}${entry.brief}${source}`;
145
+ }
146
+
147
+ /**
148
+ * A planned task's request without the plan in it (its title, and where it
149
+ * sits in a split plan), for prompts that carry the plan on its own: the plan
150
+ * is not sent twice. Any other request is returned whole.
151
+ */
152
+ export function requestBesidePlan(request: string): string {
153
+ const at = request.indexOf(AGREED_PLAN);
154
+ return at < 0 ? request : `${request.slice(0, at)}\n\n(The agreed plan is the task's plan.)`;
139
155
  }
@@ -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
  }