@a-t-h-i/bot-lobby 0.6.36 → 0.6.38

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 +32 -7
  2. package/package.json +1 -1
  3. package/prompts/master.md +32 -0
  4. package/prompts/worker.md +11 -0
  5. package/src/classifier/effort.ts +14 -2
  6. package/src/classifier/instance.ts +10 -2
  7. package/src/classifier/knowledge.ts +5 -2
  8. package/src/classifier/scripts.ts +76 -0
  9. package/src/execution/agent-runner.ts +75 -14
  10. package/src/execution/delegation.ts +25 -7
  11. package/src/execution/pi-runner.ts +9 -3
  12. package/src/knowledge/selector.ts +2 -0
  13. package/src/lobby/quickfix.ts +10 -2
  14. package/src/lobby/runtime.ts +2 -1
  15. package/src/lobby/service.ts +8 -1
  16. package/src/lobby/sessions.ts +1 -1
  17. package/src/master/master.ts +19 -6
  18. package/src/pi/activity.ts +2 -0
  19. package/src/pi/auto-compaction.ts +4 -4
  20. package/src/pi/events.ts +4 -1
  21. package/src/pi/plan-checklist.ts +4 -2
  22. package/src/pi/start-task.ts +15 -9
  23. package/src/pi/task-title.ts +46 -0
  24. package/src/pi/tools.ts +6 -4
  25. package/src/prompts/compiler.ts +4 -1
  26. package/src/roles/worker.ts +15 -1
  27. package/src/schemas/configuration.ts +5 -2
  28. package/src/schemas/findings.ts +14 -0
  29. package/src/scripts/library.ts +168 -0
  30. package/src/scripts/pick.ts +79 -0
  31. package/src/state/step-limits.ts +41 -0
  32. package/src/state/work-time.ts +16 -11
  33. package/src/webui/api/lobby.ts +1 -1
  34. package/src/webui/api/plan-facts.ts +3 -2
  35. package/src/webui/protocol.ts +1 -1
  36. package/src/workflow/workflow.ts +122 -40
  37. package/webui/dist/assets/index-BFsMFIr5.js +110 -0
  38. package/webui/dist/assets/index-Ccc6rsPD.css +2 -0
  39. package/webui/dist/build.json +99 -79
  40. package/webui/dist/index.html +2 -2
  41. package/webui/dist/assets/index-4Zm1DbOM.css +0 -2
  42. package/webui/dist/assets/index-CjhlNym4.js +0 -109
@@ -0,0 +1,168 @@
1
+ /**
2
+ * The scripts library: scripts every agent shares for jobs that come back
3
+ * (seeding data, regenerating fixtures, a set of checks, a migration), so a
4
+ * step done once is run the next time, not worked out again, which saves
5
+ * tokens and time. They live in `<data>/scripts/`, one file each, named for
6
+ * what they do in snake_case with the language's extension
7
+ * (`seed_public_holidays.py`, `run_changed_tests.sh`). The first lines of each
8
+ * file say what it is for and how to run it, and the library's index is read
9
+ * from them, so a script edited by hand stays listed. Only the oracle adds or
10
+ * replaces a script; workers propose one. A replaced script is kept under
11
+ * `archive/`.
12
+ */
13
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
14
+ import { join } from "node:path";
15
+
16
+ /** The languages a script may be written in: how it is run and how its header lines are commented. */
17
+ export const SCRIPT_LANGUAGES = {
18
+ py: { runner: "python3", comment: "#" },
19
+ sh: { runner: "bash", comment: "#" },
20
+ bash: { runner: "bash", comment: "#" },
21
+ js: { runner: "node", comment: "//" },
22
+ mjs: { runner: "node", comment: "//" },
23
+ cjs: { runner: "node", comment: "//" },
24
+ ts: { runner: "node", comment: "//" },
25
+ rb: { runner: "ruby", comment: "#" },
26
+ pl: { runner: "perl", comment: "#" },
27
+ ps1: { runner: "pwsh -File", comment: "#" },
28
+ } as const;
29
+ export type ScriptLanguage = keyof typeof SCRIPT_LANGUAGES;
30
+
31
+ /** Longest name, extension included. */
32
+ export const MAX_SCRIPT_NAME = 64;
33
+ /** Largest script the library takes. */
34
+ export const MAX_SCRIPT_BYTES = 64 * 1024;
35
+
36
+ const NAME = new RegExp(`^[a-z][a-z0-9]*(?:_[a-z0-9]+)+\\.(${Object.keys(SCRIPT_LANGUAGES).join("|")})$`);
37
+ /** How many lines from the top the header is looked for in. */
38
+ const HEADER_LINES = 12;
39
+
40
+ export interface ScriptEntry {
41
+ /** The file name: `seed_public_holidays.py`. */
42
+ name: string;
43
+ path: string;
44
+ language: ScriptLanguage;
45
+ /** What it is for, one line. */
46
+ purpose: string;
47
+ /** How to run it, with its arguments; the runner and path when the header gives none. */
48
+ usage: string;
49
+ }
50
+
51
+ export function scriptsDir(dataRoot: string): string {
52
+ return join(dataRoot, "scripts");
53
+ }
54
+
55
+ /** The words of a name (`seed_public_holidays.py` → `seed public holidays`), for matching it against a step. */
56
+ export function nameWords(name: string): string {
57
+ return name.replace(/\.[a-z0-9]+$/, "").replace(/_/g, " ");
58
+ }
59
+
60
+ /**
61
+ * The library name for what was asked: lower-cased, spaces and hyphens as
62
+ * underscores (`Seed public-holidays.py` → `seed_public_holidays.py`). Throws
63
+ * with what is wrong when it still does not read `<what_it_does>.<ext>`.
64
+ */
65
+ export function scriptName(asked: string): string {
66
+ const name = asked.trim().toLowerCase().replace(/[\s-]+/g, "_").replace(/_+/g, "_");
67
+ const languages = Object.keys(SCRIPT_LANGUAGES).map((ext) => `.${ext}`).join(", ");
68
+ if (!/\.[a-z0-9]+$/.test(name) || !(name.split(".").pop()! in SCRIPT_LANGUAGES)) {
69
+ throw new Error(`a script name ends in its language's extension (${languages}): "${asked}"`);
70
+ }
71
+ if (name.length > MAX_SCRIPT_NAME) throw new Error(`a script name is at most ${MAX_SCRIPT_NAME} characters: "${asked}"`);
72
+ if (!NAME.test(name)) {
73
+ throw new Error(`a script is named for what it does, in snake_case words (seed_public_holidays.py): "${asked}"`);
74
+ }
75
+ return name;
76
+ }
77
+
78
+ function language(name: string): ScriptLanguage {
79
+ return name.split(".").pop() as ScriptLanguage;
80
+ }
81
+
82
+ function headerValue(lines: readonly string[], key: string): string | undefined {
83
+ const line = lines.find((entry) => new RegExp(`^\\s*(?:#|//)\\s*${key}\\s*:`, "i").test(entry));
84
+ return line?.replace(new RegExp(`^\\s*(?:#|//)\\s*${key}\\s*:\\s*`, "i"), "").trim() || undefined;
85
+ }
86
+
87
+ /** The command that runs a script when its header gives none. */
88
+ export function runCommand(entry: Pick<ScriptEntry, "path" | "language">): string {
89
+ return `${SCRIPT_LANGUAGES[entry.language].runner} ${entry.path}`;
90
+ }
91
+
92
+ function readEntry(dir: string, name: string): ScriptEntry | undefined {
93
+ const path = join(dir, name);
94
+ try {
95
+ if (!statSync(path).isFile()) return undefined;
96
+ const lines = readFileSync(path, "utf8").split("\n").slice(0, HEADER_LINES);
97
+ const entry = { name, path, language: language(name) };
98
+ return { ...entry, purpose: headerValue(lines, "purpose") ?? nameWords(name), usage: headerValue(lines, "usage") ?? runCommand(entry) };
99
+ } catch {
100
+ return undefined;
101
+ }
102
+ }
103
+
104
+ /** Every script in the library, by name; files not named like a script are left out. */
105
+ export function listScripts(dataRoot: string): ScriptEntry[] {
106
+ const dir = scriptsDir(dataRoot);
107
+ if (!existsSync(dir)) return [];
108
+ return readdirSync(dir)
109
+ .filter((name) => NAME.test(name))
110
+ .sort()
111
+ .map((name) => readEntry(dir, name))
112
+ .filter((entry): entry is ScriptEntry => entry !== undefined);
113
+ }
114
+
115
+ /** The body with any header lines of its own taken out, and a shebang it starts with kept apart. */
116
+ function splitBody(body: string): { shebang?: string; rest: string } {
117
+ const lines = body.replace(/\r\n/g, "\n").split("\n");
118
+ const shebang = lines[0]?.startsWith("#!") ? lines.shift() : undefined;
119
+ const rest = lines.filter((line, index) => index >= HEADER_LINES || !/^\s*(?:#|\/\/)\s*(?:purpose|usage)\s*:/i.test(line));
120
+ while (rest.length > 0 && rest[0]!.trim() === "") rest.shift();
121
+ return { ...(shebang ? { shebang } : {}), rest: rest.join("\n") };
122
+ }
123
+
124
+ export interface SaveScript {
125
+ dataRoot: string;
126
+ name: string;
127
+ purpose: string;
128
+ /** How to run it, with its arguments; the runner and path when absent. */
129
+ usage?: string;
130
+ body: string;
131
+ }
132
+
133
+ export interface SavedScript {
134
+ entry: ScriptEntry;
135
+ /** Where the version it replaced was kept, when there was one. */
136
+ archived?: string;
137
+ }
138
+
139
+ /**
140
+ * Add a script to the library, or replace the one of that name (the old one
141
+ * is kept under `archive/`). The engine writes its header: purpose and usage
142
+ * as comments after any shebang, so the index never depends on the agent
143
+ * having written one.
144
+ */
145
+ export function saveScript(input: SaveScript, now = new Date()): SavedScript {
146
+ const name = scriptName(input.name);
147
+ const purpose = input.purpose.replace(/\s+/g, " ").trim();
148
+ if (!purpose) throw new Error("a script needs its purpose: one line on what it does");
149
+ const { shebang, rest } = splitBody(input.body);
150
+ if (!rest.trim()) throw new Error("the script is empty");
151
+ const dir = scriptsDir(input.dataRoot);
152
+ const path = join(dir, name);
153
+ const lang = language(name);
154
+ const usage = input.usage?.replace(/\s+/g, " ").trim() || runCommand({ path, language: lang });
155
+ const { comment } = SCRIPT_LANGUAGES[lang];
156
+ const text = [...(shebang ? [shebang] : []), `${comment} purpose: ${purpose}`, `${comment} usage: ${usage}`, "", rest.replace(/\s+$/, ""), ""].join("\n");
157
+ if (Buffer.byteLength(text) > MAX_SCRIPT_BYTES) throw new Error(`a script is at most ${MAX_SCRIPT_BYTES / 1024} KB`);
158
+ mkdirSync(dir, { recursive: true });
159
+ let archived: string | undefined;
160
+ if (existsSync(path)) {
161
+ if (readFileSync(path, "utf8") === text) return { entry: readEntry(dir, name)! };
162
+ mkdirSync(join(dir, "archive"), { recursive: true });
163
+ archived = join(dir, "archive", `${name}.${now.toISOString().replace(/[:.]/g, "-")}`);
164
+ copyFileSync(path, archived);
165
+ }
166
+ writeFileSync(path, text, { mode: 0o755 });
167
+ return { entry: readEntry(dir, name)!, ...(archived ? { archived } : {}) };
168
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Which of the library's scripts fit a step, and how an agent's prompt shows
3
+ * them. Jev judges each script against the step when it is on (see
4
+ * `classifier/scripts.ts`); without it, or when it fails, a script fits when
5
+ * its name or purpose shares words with the step. Either way the prompt names
6
+ * the library, so an agent can look for itself.
7
+ */
8
+ import { score, tokenize } from "../knowledge/selector.ts";
9
+ import { listScripts, nameWords, scriptsDir, type ScriptEntry } from "./library.ts";
10
+
11
+ /** Most scripts one prompt suggests. */
12
+ export const MAX_SUGGESTED = 6;
13
+ /** Most scripts the oracle's prompt lists. */
14
+ export const MAX_LISTED = 40;
15
+
16
+ /** The scripts whose name or purpose shares the most words with the step, best first. */
17
+ export function keywordScripts(step: string, entries: readonly ScriptEntry[], limit = MAX_SUGGESTED): ScriptEntry[] {
18
+ const tokens = tokenize(step);
19
+ return entries
20
+ .map((entry, index) => ({ entry, index, hits: score(`${nameWords(entry.name)} ${entry.purpose}`, tokens) }))
21
+ .filter((scored) => scored.hits > 0)
22
+ .sort((a, b) => b.hits - a.hits || a.index - b.index)
23
+ .slice(0, limit)
24
+ .map((scored) => scored.entry);
25
+ }
26
+
27
+ /** Picks the scripts that fit a step. */
28
+ export interface ScriptPicker {
29
+ pick(step: string, entries: readonly ScriptEntry[], options?: { signal?: AbortSignal }): Promise<ScriptEntry[]>;
30
+ }
31
+
32
+ /** The picker that goes by shared words alone. */
33
+ export const keywordPicker: ScriptPicker = { pick: async (step, entries) => keywordScripts(step, entries) };
34
+
35
+ function line(entry: ScriptEntry): string {
36
+ return `- \`${entry.name}\` — ${entry.purpose}\n run: \`${entry.usage}\``;
37
+ }
38
+
39
+ /**
40
+ * The Scripts layer of a worker's, scout's or QA agent's prompt: the scripts
41
+ * that fit this step and how to run each, then where the rest are. "" while
42
+ * the library is empty.
43
+ */
44
+ export function scriptsBlock(dataRoot: string, entries: readonly ScriptEntry[], picked: readonly ScriptEntry[]): string {
45
+ if (entries.length === 0) return "";
46
+ const where = `The shared scripts library (${entries.length} script${entries.length === 1 ? "" : "s"}) is \`${scriptsDir(dataRoot)}\`; each file's first lines say what it does and how to run it.`;
47
+ if (picked.length === 0) return `None of the shared scripts fits this step. ${where}`;
48
+ return [
49
+ "These shared scripts do part of this step: run one instead of doing its work by hand, and check its output as you would your own.",
50
+ ...picked.map(line),
51
+ where,
52
+ ].join("\n");
53
+ }
54
+
55
+ /** The oracle's view of the library: every script by name and purpose, so it can point a brief at one. */
56
+ export function libraryBlock(dataRoot: string, entries: readonly ScriptEntry[] = listScripts(dataRoot)): string {
57
+ const dir = scriptsDir(dataRoot);
58
+ if (entries.length === 0) return `The shared scripts library (\`${dir}\`) is empty.`;
59
+ const shown = entries.slice(0, MAX_LISTED).map((entry) => `- \`${entry.name}\` — ${entry.purpose}`);
60
+ const more = entries.length > MAX_LISTED ? [`…and ${entries.length - MAX_LISTED} more in \`${dir}\`.`] : [];
61
+ return [`Shared scripts (\`${dir}\`):`, ...shown, ...more].join("\n");
62
+ }
63
+
64
+ /**
65
+ * The Scripts layer for one agent's step: the library read, the picker's
66
+ * choice (shared words when it fails), and the block. "" with no scripts.
67
+ */
68
+ export async function scriptsFor(dataRoot: string | undefined, step: string, picker: ScriptPicker = keywordPicker, signal?: AbortSignal): Promise<string> {
69
+ if (!dataRoot) return "";
70
+ const entries = listScripts(dataRoot);
71
+ if (entries.length === 0) return "";
72
+ let picked: ScriptEntry[];
73
+ try {
74
+ picked = await picker.pick(step, entries, signal ? { signal } : {});
75
+ } catch {
76
+ picked = keywordScripts(step, entries);
77
+ }
78
+ return scriptsBlock(dataRoot, entries, picked.slice(0, MAX_SUGGESTED));
79
+ }
@@ -0,0 +1,41 @@
1
+ import { readWork, updateWork } from "./work-time.ts";
2
+
3
+ export const STEP_LIMIT_MS = 15 * 60_000;
4
+
5
+ export function stepTimeLeft(root: string, configDir: string, taskId: string, keys: readonly string[], limitMs: number): number {
6
+ const limits = readWork(root, configDir, taskId, true)?.stepLimits;
7
+ return Math.max(0, Math.min(limitMs, ...keys.map((key) => {
8
+ const entry = limits?.[key];
9
+ if (!entry) return limitMs;
10
+ if (!Number.isFinite(entry.limitMs) || !Number.isFinite(entry.usedMs) || entry.limitMs < 0 || entry.usedMs < 0) throw new Error("Invalid step allowance");
11
+ return entry.limitMs - entry.usedMs;
12
+ })));
13
+ }
14
+
15
+ /** Reserve before starting: a crash must not silently give the step a fresh allowance. */
16
+ export function reserveStepTime(root: string, configDir: string, taskId: string, keys: readonly string[], limitMs: number, ms: number) {
17
+ let reserved = ms;
18
+ const change = (fn: (entry: { limitMs: number; usedMs: number }) => void) => updateWork(root, configDir, taskId, (work) => {
19
+ work.stepLimits ??= {};
20
+ for (const key of keys) fn(work.stepLimits[key] ??= { limitMs, usedMs: 0 });
21
+ }, new Date(), true);
22
+ if (!(ms > 0) || ms > stepTimeLeft(root, configDir, taskId, keys, limitMs)) throw new Error("The step has no time left");
23
+ change((entry) => { entry.usedMs += ms; });
24
+ return {
25
+ grant(extraMs: number) {
26
+ change((entry) => { entry.limitMs += extraMs; entry.usedMs += extraMs; });
27
+ reserved += extraMs;
28
+ },
29
+ finish(usedMs: number, exhausted = false) {
30
+ const refund = exhausted ? 0 : Math.max(0, reserved - usedMs);
31
+ change((entry) => { entry.usedMs = Math.max(0, entry.usedMs - refund); });
32
+ },
33
+ };
34
+ }
35
+
36
+ export function grantStepTime(root: string, configDir: string, taskId: string, keys: readonly string[], limitMs: number, extraMs: number): void {
37
+ updateWork(root, configDir, taskId, (work) => {
38
+ work.stepLimits ??= {};
39
+ for (const key of keys) (work.stepLimits[key] ??= { limitMs, usedMs: 0 }).limitMs += extraMs;
40
+ }, new Date(), true);
41
+ }
@@ -29,6 +29,7 @@ export interface WorkTime {
29
29
  active: ActiveRun[];
30
30
  /** When the owning session last wrote it. */
31
31
  updatedAt: string;
32
+ stepLimits?: Record<string, { limitMs: number; usedMs: number }>;
32
33
  }
33
34
 
34
35
  /** The clock as a reader sees it at one moment. */
@@ -48,32 +49,36 @@ export function workPath(root: string, configDir: string, taskId: string): strin
48
49
 
49
50
  function isWork(value: unknown): value is WorkTime {
50
51
  const work = value as Partial<WorkTime> | undefined;
51
- return Boolean(work && typeof work.workedMs === "number" && typeof work.updatedAt === "string");
52
+ return Boolean(work && Number.isFinite(work.workedMs) && typeof work.updatedAt === "string"
53
+ && (work.stepLimits === undefined || (work.stepLimits !== null && typeof work.stepLimits === "object" && !Array.isArray(work.stepLimits)
54
+ && Object.values(work.stepLimits).every((entry) => entry && Number.isFinite(entry.limitMs) && Number.isFinite(entry.usedMs) && entry.limitMs >= 0 && entry.usedMs >= 0))));
52
55
  }
53
56
 
54
- export function readWork(root: string, configDir: string, taskId: string): WorkTime | undefined {
57
+ export function readWork(root: string, configDir: string, taskId: string, strict = false): WorkTime | undefined {
55
58
  try {
56
59
  const value: unknown = JSON.parse(readFileSync(workPath(root, configDir, taskId), "utf8"));
57
- if (!isWork(value)) return undefined;
58
- return { workedMs: value.workedMs, ...(value.runningSince ? { runningSince: value.runningSince } : {}), active: Array.isArray(value.active) ? value.active : [], updatedAt: value.updatedAt };
59
- } catch {
60
+ if (!isWork(value)) throw new Error("Invalid task work record");
61
+ return { workedMs: value.workedMs, ...(value.runningSince ? { runningSince: value.runningSince } : {}), active: Array.isArray(value.active) ? value.active : [], updatedAt: value.updatedAt, ...(value.stepLimits ? { stepLimits: value.stepLimits } : {}) };
62
+ } catch (error) {
63
+ if (strict && (error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
60
64
  return undefined;
61
65
  }
62
66
  }
63
67
 
64
68
  /** Change a task's work time in place (read afresh, written back atomically), starting it when there is none. */
65
- export function updateWork(root: string, configDir: string, taskId: string, change: (work: WorkTime) => void, now = new Date()): void {
66
- const work = readWork(root, configDir, taskId) ?? { workedMs: 0, active: [], updatedAt: now.toISOString() };
67
- change(work);
68
- work.updatedAt = now.toISOString();
69
+ export function updateWork(root: string, configDir: string, taskId: string, change: (work: WorkTime) => void, now = new Date(), strict = false): void {
69
70
  try {
71
+ const work = readWork(root, configDir, taskId, true) ?? { workedMs: 0, active: [], updatedAt: now.toISOString() };
72
+ change(work);
73
+ work.updatedAt = now.toISOString();
70
74
  const path = workPath(root, configDir, taskId);
71
75
  mkdirSync(dirname(path), { recursive: true });
72
76
  const tmp = `${path}.${process.pid}.tmp`;
73
77
  writeFileSync(tmp, `${JSON.stringify(work, null, 2)}\n`, "utf8");
74
78
  renameSync(tmp, path);
75
- } catch {
76
- // A read-only tree must never fail the workflow; the time just stops being saved.
79
+ } catch (error) {
80
+ if (strict) throw error;
81
+ // Display clocks may stop; never overwrite an unreadable allowance ledger.
77
82
  }
78
83
  }
79
84
 
@@ -24,7 +24,7 @@ function planFacts(task: Task, work: WorkProjection | undefined): Pick<SnapshotT
24
24
  return {
25
25
  progress: { done: steps.filter((step) => step.status === "done").length, total: steps.length },
26
26
  ...(current ? { currentStep: current.text } : {}),
27
- ...(active.length > 0 ? { activeSteps: active.map((step) => ({ text: step.text, workedMs: step.workedMs, activeAgentCount: step.activeAgentCount })) } : {}),
27
+ ...(active.length > 0 ? { activeSteps: active.map((step) => ({ text: step.text, workedMs: step.workedMs, activeAgentCount: step.activeAgentCount, runIds: step.runIds })) } : {}),
28
28
  ...clock,
29
29
  };
30
30
  }
@@ -39,6 +39,7 @@ export interface StepView {
39
39
  status: PlanStep["status"];
40
40
  active: boolean;
41
41
  activeAgentCount: number;
42
+ runIds: string[];
42
43
  /** Worker time on the step, in ms. */
43
44
  workedMs: number;
44
45
  }
@@ -54,7 +55,7 @@ export function stepViews(task: Task, work: WorkProjection | undefined, now = Da
54
55
  const checklist = planChecklist(task.plan, runs);
55
56
  const times = stepWork(planSteps(task.plan), runs, now);
56
57
  return checklist.map((step, index) => {
57
- const time = times[index] ?? { ms: 0, active: false, activeAgentCount: 0 };
58
- return { text: step.text, status: time.active ? "current" : step.status, active: time.active, activeAgentCount: time.activeAgentCount, workedMs: Math.round(time.ms) };
58
+ const time = times[index] ?? { ms: 0, active: false, activeAgentCount: 0, runIds: [] };
59
+ return { text: step.text, status: time.active ? "current" : step.status, active: time.active, activeAgentCount: time.activeAgentCount, runIds: time.runIds, workedMs: Math.round(time.ms) };
59
60
  });
60
61
  }
@@ -131,7 +131,7 @@ export interface SnapshotTask {
131
131
  progress?: { done: number; total: number };
132
132
  currentStep?: string;
133
133
  /** The steps a worker is on right now, with the worker time on each (ms, as of the snapshot). */
134
- activeSteps?: Array<{ text: string; workedMs: number; activeAgentCount?: number }>;
134
+ activeSteps?: Array<{ text: string; workedMs: number; activeAgentCount?: number; runIds?: string[] }>;
135
135
  /** How long the task's agents have worked on it, idle time left out. */
136
136
  work?: WorkClock;
137
137
  }