@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.
- package/README.md +10 -2
- package/package.json +1 -5
- package/prompts/master.md +36 -10
- package/prompts/planner.md +5 -2
- package/prompts/qa.md +139 -77
- package/prompts/reviewer.md +4 -0
- package/src/classifier/hosts.ts +0 -11
- package/src/classifier/instance.ts +0 -5
- package/src/classifier/qa-risk.ts +537 -0
- package/src/execution/agent-runner.ts +0 -18
- package/src/knowledge/compactor.ts +0 -70
- package/src/lobby/ask.ts +0 -15
- package/src/lobby/host.ts +2 -0
- package/src/lobby/issues.ts +0 -8
- package/src/lobby/planner.ts +14 -0
- package/src/lobby/pulls.ts +1 -44
- package/src/lobby/runtime.ts +1 -1
- package/src/lobby/service.ts +3 -1
- package/src/lobby/session-files.ts +1 -45
- package/src/lobby/split.ts +33 -1
- package/src/lobby/task-rows.ts +2 -0
- package/src/master/decisions.ts +5 -2
- package/src/master/master.ts +6 -0
- package/src/master/research.ts +0 -16
- package/src/pi/events.ts +13 -6
- package/src/pi/model-settings.ts +0 -1
- package/src/pi/plan-checklist.ts +98 -8
- package/src/pi/route.ts +0 -1
- package/src/pi/run-summary.ts +0 -57
- package/src/pi/start-task.ts +31 -4
- package/src/pi/tools.ts +3 -0
- package/src/schemas/agent.ts +0 -4
- package/src/schemas/configuration.ts +2 -4
- package/src/schemas/task.ts +32 -0
- package/src/state/backlog.ts +19 -3
- package/src/state/budget.ts +49 -22
- package/src/state/metrics.ts +0 -1
- package/src/state/persistence.ts +0 -5
- package/src/state/work-time.ts +123 -0
- package/src/webui/api/index.ts +0 -5
- package/src/webui/api/lobby.ts +13 -6
- package/src/webui/api/plan-facts.ts +36 -6
- package/src/webui/api/tasks.ts +28 -6
- package/src/webui/dev/fake-service.ts +6 -0
- package/src/webui/dev/fixtures.ts +2 -0
- package/src/webui/protocol.ts +23 -3
- package/src/workflow/track.ts +46 -7
- package/src/workflow/workflow.ts +231 -42
- package/webui/dist/assets/index-BUzOUrih.css +2 -0
- package/webui/dist/assets/index-myC4UiEg.js +108 -0
- package/webui/dist/build.json +37 -41
- package/webui/dist/index.html +2 -2
- package/webui/dist/assets/index-BRVbs5In.js +0 -105
- package/webui/dist/assets/index-BeRXVi_r.css +0 -2
package/src/pi/start-task.ts
CHANGED
|
@@ -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
|
|
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
|
|
package/src/schemas/agent.ts
CHANGED
|
@@ -33,7 +33,3 @@ export interface DomainSpec {
|
|
|
33
33
|
export function isDomain(value: string): value is Domain {
|
|
34
34
|
return (DOMAINS as readonly string[]).includes(value);
|
|
35
35
|
}
|
|
36
|
-
|
|
37
|
-
export function isRole(value: string): value is Role {
|
|
38
|
-
return (ROLES as readonly string[]).includes(value);
|
|
39
|
-
}
|
|
@@ -13,8 +13,6 @@ export type ThinkingLevelName = (typeof THINKING_LEVELS)[number];
|
|
|
13
13
|
*/
|
|
14
14
|
const INHERIT = "inherit";
|
|
15
15
|
export const INHERIT_MODEL = INHERIT;
|
|
16
|
-
/** Legacy thinking sentinel; configs that still carry it migrate to `DEFAULT_THINKING`. */
|
|
17
|
-
export const INHERIT_THINKING = INHERIT;
|
|
18
16
|
/** Level used for a missing, legacy `inherit` or unknown thinking value. */
|
|
19
17
|
export const DEFAULT_THINKING: ThinkingLevelName = "medium";
|
|
20
18
|
/** Scouts are reconnaissance: always fast, never configurable. */
|
|
@@ -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,
|
package/src/schemas/task.ts
CHANGED
|
@@ -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
|
}
|
package/src/state/backlog.ts
CHANGED
|
@@ -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}
|
|
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
|
}
|
package/src/state/budget.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
11
11
|
import { dirname, join } from "node:path";
|
|
12
12
|
import { taskDirFor } from "./persistence.ts";
|
|
13
|
-
import {
|
|
13
|
+
import { clearStaleRuns, updateWork } from "./work-time.ts";
|
|
14
14
|
|
|
15
15
|
/** What one delegation was given. */
|
|
16
16
|
export interface Allotment {
|
|
@@ -74,18 +74,6 @@ function writeBudget(root: string, configDir: string, taskId: string, budget: Ta
|
|
|
74
74
|
writeFileSync(path, `${JSON.stringify({ ...budget, allotments: budget.allotments.slice(-MAX_ALLOTMENTS) }, null, 2)}\n`, "utf8");
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
/** A task's budget as a display reads it every frame: parsed again only when the file changes. */
|
|
78
|
-
export function peekBudget(root: string, configDir: string, taskId: string): TaskBudget | undefined {
|
|
79
|
-
const value = readJsonCached(budgetPath(root, configDir, taskId), isBudget);
|
|
80
|
-
return value ? { minutes: value.minutes, granted: value.granted ?? 0, usedMs: value.usedMs, allotments: Array.isArray(value.allotments) ? value.allotments : [] } : undefined;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** Work time spent of the whole, for the lobby's status box; undefined without a budget. */
|
|
84
|
-
export function budgetClock(root: string, configDir: string, taskId: string, now = Date.now()): { usedMs: number; totalMs: number } | undefined {
|
|
85
|
-
const budget = peekBudget(root, configDir, taskId);
|
|
86
|
-
return budget ? { usedMs: usedMs(taskId, budget, now), totalMs: (budget.minutes + budget.granted) * 60_000 } : undefined;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
77
|
/** Change a task's budget in place (read afresh, written back); undefined when it has none. */
|
|
90
78
|
export function updateBudget(root: string, configDir: string, taskId: string, change: (budget: TaskBudget) => void): TaskBudget | undefined {
|
|
91
79
|
const budget = readBudget(root, configDir, taskId);
|
|
@@ -127,6 +115,11 @@ export function formatMinutes(ms: number): string {
|
|
|
127
115
|
}
|
|
128
116
|
|
|
129
117
|
/* ------------------------------------------------------------ work clock */
|
|
118
|
+
/*
|
|
119
|
+
* Every task the session drives has a work clock, budget or not: it keeps the
|
|
120
|
+
* task's work time (work.json, what the lobby shows as "worked") and, when the
|
|
121
|
+
* task has a budget, the time spent of it.
|
|
122
|
+
*/
|
|
130
123
|
|
|
131
124
|
interface Clock {
|
|
132
125
|
root: string;
|
|
@@ -141,41 +134,74 @@ interface Clock {
|
|
|
141
134
|
|
|
142
135
|
/** Work clocks of the tasks this process drives. */
|
|
143
136
|
const clocks = new Map<string, Clock>();
|
|
144
|
-
/** How often a
|
|
137
|
+
/** How often a running clock is saved, so other windows see time pass (and see it is still kept). */
|
|
145
138
|
export const CLOCK_FLUSH_MS = 30_000;
|
|
146
139
|
let flusher: ReturnType<typeof setInterval> | undefined;
|
|
140
|
+
/** The oracle's turn is running in this process. */
|
|
141
|
+
let turn = false;
|
|
147
142
|
|
|
148
|
-
/** Fold the stretch that counted into the saved
|
|
143
|
+
/** Fold the stretch that counted into the saved totals, and start a new one if it still counts. */
|
|
149
144
|
function settle(taskId: string, clock: Clock, now = Date.now()): void {
|
|
145
|
+
let spent = 0;
|
|
150
146
|
if (clock.since !== undefined) {
|
|
151
|
-
|
|
147
|
+
spent = Math.max(0, now - clock.since);
|
|
152
148
|
clock.since = undefined;
|
|
153
149
|
if (spent > 0) updateBudget(clock.root, clock.configDir, taskId, (budget) => { budget.usedMs += spent; });
|
|
154
150
|
}
|
|
155
151
|
if (clock.running && clock.waits === 0) clock.since = now;
|
|
152
|
+
const since = clock.since;
|
|
153
|
+
updateWork(clock.root, clock.configDir, taskId, (work) => {
|
|
154
|
+
work.workedMs += spent;
|
|
155
|
+
if (since !== undefined) work.runningSince = new Date(since).toISOString();
|
|
156
|
+
else delete work.runningSince;
|
|
157
|
+
}, new Date(now));
|
|
156
158
|
}
|
|
157
159
|
|
|
158
160
|
function ensureFlusher(): void {
|
|
159
161
|
if (flusher) return;
|
|
160
162
|
flusher = setInterval(() => {
|
|
161
|
-
for (const [taskId, clock] of clocks) if (clock.
|
|
163
|
+
for (const [taskId, clock] of clocks) if (clock.running) settle(taskId, clock);
|
|
162
164
|
}, CLOCK_FLUSH_MS);
|
|
163
165
|
flusher.unref();
|
|
164
166
|
}
|
|
165
167
|
|
|
166
|
-
/** The oracle
|
|
168
|
+
/** The oracle's turn started: a task it drives, or starts within the turn, is being worked on. */
|
|
169
|
+
export function turnStarted(): void {
|
|
170
|
+
turn = true;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The oracle started a turn on a task (or took one up within it): its clock runs. */
|
|
167
174
|
export function startClock(root: string, configDir: string, taskId: string): void {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
175
|
+
let clock = clocks.get(taskId);
|
|
176
|
+
if (!clock) {
|
|
177
|
+
clock = { root, configDir, running: false, waits: 0 };
|
|
178
|
+
clocks.set(taskId, clock);
|
|
179
|
+
clearStaleRuns(root, configDir, taskId);
|
|
180
|
+
}
|
|
181
|
+
if (clock.running) return;
|
|
171
182
|
clock.running = true;
|
|
172
183
|
settle(taskId, clock);
|
|
173
184
|
ensureFlusher();
|
|
174
185
|
}
|
|
175
186
|
|
|
176
|
-
/**
|
|
187
|
+
/**
|
|
188
|
+
* The task the session drives now, after a step that may have started, ended or taken over one: while
|
|
189
|
+
* a turn runs, its clock runs and any other task's stops.
|
|
190
|
+
*/
|
|
191
|
+
export function clockTask(root: string, configDir: string, taskId: string | undefined): void {
|
|
192
|
+
for (const [id, clock] of clocks) {
|
|
193
|
+
if (id === taskId || !clock.running) continue;
|
|
194
|
+
clock.running = false;
|
|
195
|
+
settle(id, clock);
|
|
196
|
+
}
|
|
197
|
+
if (taskId && turn) startClock(root, configDir, taskId);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** The oracle's turn ended: the clocks stop until the next one. */
|
|
177
201
|
export function stopClocks(): void {
|
|
202
|
+
turn = false;
|
|
178
203
|
for (const [taskId, clock] of clocks) {
|
|
204
|
+
if (!clock.running && clock.since === undefined) continue;
|
|
179
205
|
clock.running = false;
|
|
180
206
|
settle(taskId, clock);
|
|
181
207
|
}
|
|
@@ -215,6 +241,7 @@ export function usedMs(taskId: string, budget: TaskBudget, now = Date.now()): nu
|
|
|
215
241
|
/** Forget the clocks (tests; a session that ends). */
|
|
216
242
|
export function resetClocks(): void {
|
|
217
243
|
clocks.clear();
|
|
244
|
+
turn = false;
|
|
218
245
|
if (flusher) clearInterval(flusher);
|
|
219
246
|
flusher = undefined;
|
|
220
247
|
}
|
package/src/state/metrics.ts
CHANGED
|
@@ -356,7 +356,6 @@ export function aggregateMetrics(records: readonly MetricRecord[], by: GroupBy =
|
|
|
356
356
|
}
|
|
357
357
|
|
|
358
358
|
export type SortKey = "runs" | "avg" | "success" | "cost";
|
|
359
|
-
export const SORT_KEYS: readonly SortKey[] = ["runs", "avg", "success", "cost"];
|
|
360
359
|
|
|
361
360
|
export function sortGroups(groups: readonly MetricGroup[], key: SortKey): MetricGroup[] {
|
|
362
361
|
const score = (group: MetricGroup): number => {
|
package/src/state/persistence.ts
CHANGED
|
@@ -270,8 +270,3 @@ export function removeTaskScratchpads(root: string, configDir: string, taskId: s
|
|
|
270
270
|
if (file.startsWith("scout-")) rmSync(join(dir, file), { force: true });
|
|
271
271
|
}
|
|
272
272
|
}
|
|
273
|
-
|
|
274
|
-
/** Delete the temporary task dir after knowledge has been distilled. */
|
|
275
|
-
export function cleanupTaskDir(root: string, configDir: string, taskId: string): void {
|
|
276
|
-
rmSync(taskDir(dataRoot(root, configDir), taskId), { recursive: true, force: true });
|
|
277
|
-
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How long a task's agents have actually worked on it, and which worker runs
|
|
3
|
+
* are under way. Work time is the oracle's turns on the task — which include
|
|
4
|
+
* every agent it runs — and not the time it waits on the user (between turns,
|
|
5
|
+
* and while a dialog is open); the work clock in budget.ts measures it. The
|
|
6
|
+
* session that owns the task keeps it beside the task's state as work.json,
|
|
7
|
+
* refreshed while it works, so every window can show the time ticking and
|
|
8
|
+
* mark the steps being worked on. A file its owner stopped refreshing (the
|
|
9
|
+
* session ended or crashed) reads as stopped.
|
|
10
|
+
*/
|
|
11
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
import { taskDirFor } from "./persistence.ts";
|
|
14
|
+
|
|
15
|
+
/** A worker run under way: the step it works on is found from its instruction. */
|
|
16
|
+
export interface ActiveRun {
|
|
17
|
+
runId: string;
|
|
18
|
+
instruction: string;
|
|
19
|
+
startedAt: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface WorkTime {
|
|
23
|
+
/** Work time folded in so far, in ms. */
|
|
24
|
+
workedMs: number;
|
|
25
|
+
/** While the agents work: since when the current stretch counts. */
|
|
26
|
+
runningSince?: string;
|
|
27
|
+
active: ActiveRun[];
|
|
28
|
+
/** When the owning session last wrote it. */
|
|
29
|
+
updatedAt: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The clock as a reader sees it at one moment. */
|
|
33
|
+
export interface WorkProjection {
|
|
34
|
+
workedMs: number;
|
|
35
|
+
/** Still counting: the reader adds the time that passes. */
|
|
36
|
+
running: boolean;
|
|
37
|
+
active: ActiveRun[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A file older than this is no longer being kept (the owner refreshes it every 30s while it works). */
|
|
41
|
+
export const WORK_FRESH_MS = 75_000;
|
|
42
|
+
|
|
43
|
+
export function workPath(root: string, configDir: string, taskId: string): string {
|
|
44
|
+
return join(taskDirFor(root, configDir, taskId), "work.json");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isWork(value: unknown): value is WorkTime {
|
|
48
|
+
const work = value as Partial<WorkTime> | undefined;
|
|
49
|
+
return Boolean(work && typeof work.workedMs === "number" && typeof work.updatedAt === "string");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function readWork(root: string, configDir: string, taskId: string): WorkTime | undefined {
|
|
53
|
+
try {
|
|
54
|
+
const value: unknown = JSON.parse(readFileSync(workPath(root, configDir, taskId), "utf8"));
|
|
55
|
+
if (!isWork(value)) return undefined;
|
|
56
|
+
return { workedMs: value.workedMs, ...(value.runningSince ? { runningSince: value.runningSince } : {}), active: Array.isArray(value.active) ? value.active : [], updatedAt: value.updatedAt };
|
|
57
|
+
} catch {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Change a task's work time in place (read afresh, written back atomically), starting it when there is none. */
|
|
63
|
+
export function updateWork(root: string, configDir: string, taskId: string, change: (work: WorkTime) => void, now = new Date()): void {
|
|
64
|
+
const work = readWork(root, configDir, taskId) ?? { workedMs: 0, active: [], updatedAt: now.toISOString() };
|
|
65
|
+
change(work);
|
|
66
|
+
work.updatedAt = now.toISOString();
|
|
67
|
+
try {
|
|
68
|
+
const path = workPath(root, configDir, taskId);
|
|
69
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
70
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
71
|
+
writeFileSync(tmp, `${JSON.stringify(work, null, 2)}\n`, "utf8");
|
|
72
|
+
renameSync(tmp, path);
|
|
73
|
+
} catch {
|
|
74
|
+
// A read-only tree must never fail the workflow; the time just stops being saved.
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** What a reader shows now: a stale file counts up to its last refresh and nothing in it is running. */
|
|
79
|
+
export function projectWork(work: WorkTime | undefined, now = Date.now()): WorkProjection | undefined {
|
|
80
|
+
if (!work) return undefined;
|
|
81
|
+
const updated = Date.parse(work.updatedAt);
|
|
82
|
+
const fresh = Number.isFinite(updated) && now - updated <= WORK_FRESH_MS;
|
|
83
|
+
const since = work.runningSince ? Date.parse(work.runningSince) : Number.NaN;
|
|
84
|
+
const end = fresh ? now : updated;
|
|
85
|
+
const stretch = Number.isFinite(since) && Number.isFinite(end) ? Math.max(0, end - since) : 0;
|
|
86
|
+
return { workedMs: Math.max(0, work.workedMs) + stretch, running: fresh && Number.isFinite(since), active: fresh ? [...work.active] : [] };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A task's work time as a reader sees it now; undefined for a task with none (one from before it was kept). */
|
|
90
|
+
export function taskWork(root: string, configDir: string, taskId: string, now = Date.now()): WorkProjection | undefined {
|
|
91
|
+
return projectWork(readWork(root, configDir, taskId), now);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/* ------------------------------------------------------ worker runs */
|
|
95
|
+
|
|
96
|
+
/** Worker runs this process has written as under way, per task: the file changes only when a run starts or ends. */
|
|
97
|
+
const noted = new Map<string, Set<string>>();
|
|
98
|
+
|
|
99
|
+
/** A worker run of the task started or ended: the steps it works on read as active while it runs. */
|
|
100
|
+
export function noteWorkerRun(root: string, configDir: string, taskId: string, run: { runId: string; instruction?: string; startedAt: string; status: string }): void {
|
|
101
|
+
const running = run.status === "running";
|
|
102
|
+
const ids = noted.get(taskId) ?? new Set<string>();
|
|
103
|
+
if (running === ids.has(run.runId)) return;
|
|
104
|
+
if (running) ids.add(run.runId);
|
|
105
|
+
else ids.delete(run.runId);
|
|
106
|
+
noted.set(taskId, ids);
|
|
107
|
+
updateWork(root, configDir, taskId, (work) => {
|
|
108
|
+
work.active = work.active.filter((entry) => entry.runId !== run.runId);
|
|
109
|
+
if (running) work.active.push({ runId: run.runId, instruction: run.instruction ?? "", startedAt: run.startedAt });
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** This process starts keeping a task's time: runs a crashed session left marked as under way are not. */
|
|
114
|
+
export function clearStaleRuns(root: string, configDir: string, taskId: string): void {
|
|
115
|
+
if ((noted.get(taskId)?.size ?? 0) > 0) return;
|
|
116
|
+
const work = readWork(root, configDir, taskId);
|
|
117
|
+
if (work && work.active.length > 0) updateWork(root, configDir, taskId, (next) => { next.active = []; });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Forget the runs noted (tests). */
|
|
121
|
+
export function resetNotedRuns(): void {
|
|
122
|
+
noted.clear();
|
|
123
|
+
}
|
package/src/webui/api/index.ts
CHANGED
|
@@ -373,11 +373,6 @@ function buildRoutes(): Record<string, Route> {
|
|
|
373
373
|
|
|
374
374
|
const ROUTES = buildRoutes();
|
|
375
375
|
|
|
376
|
-
/** Whether the router knows a call (defined names only; never inherited ones). */
|
|
377
|
-
export function isKnownCall(name: string): boolean {
|
|
378
|
-
return Object.hasOwn(ROUTES, name);
|
|
379
|
-
}
|
|
380
|
-
|
|
381
376
|
/**
|
|
382
377
|
* Run `POST /api/<name>` with an already-parsed body. Throws `HttpError` for
|
|
383
378
|
* refusals (unknown call, a body that is no object, a schema mismatch, or a
|
package/src/webui/api/lobby.ts
CHANGED
|
@@ -9,23 +9,30 @@ import type { Task } from "../../schemas/task.ts";
|
|
|
9
9
|
import type { ApiContext } from "./index.ts";
|
|
10
10
|
import { withAttachments } from "../uploads.ts";
|
|
11
11
|
import { fail } from "./index.ts";
|
|
12
|
-
import {
|
|
12
|
+
import { stepViews } from "./plan-facts.ts";
|
|
13
|
+
import { TERMINAL_STATES } from "../../schemas/task.ts";
|
|
14
|
+
import type { WorkProjection } from "../../state/work-time.ts";
|
|
13
15
|
import type { LobbySnapshot, SnapshotTask } from "../protocol.ts";
|
|
14
16
|
|
|
15
|
-
/** Plan facts the header shows: step progress
|
|
16
|
-
function planFacts(task: Task): Pick<SnapshotTask, "progress" | "currentStep"> {
|
|
17
|
-
const
|
|
18
|
-
|
|
17
|
+
/** Plan facts the header shows: step progress, the steps a worker is on (with their time) or else the next one, and the work time. */
|
|
18
|
+
function planFacts(task: Task, work: WorkProjection | undefined): Pick<SnapshotTask, "progress" | "currentStep" | "activeSteps" | "work"> {
|
|
19
|
+
const clock = work ? { work: { workedMs: Math.round(work.workedMs), running: work.running && !TERMINAL_STATES.includes(task.state) } } : {};
|
|
20
|
+
const steps = stepViews(task, work);
|
|
21
|
+
if (steps.length === 0) return clock;
|
|
22
|
+
const active = steps.filter((step) => step.active);
|
|
19
23
|
const current = steps.find((step) => step.status === "current");
|
|
20
24
|
return {
|
|
21
25
|
progress: { done: steps.filter((step) => step.status === "done").length, total: steps.length },
|
|
22
26
|
...(current ? { currentStep: current.text } : {}),
|
|
27
|
+
...(active.length > 0 ? { activeSteps: active.map((step) => ({ text: step.text, workedMs: step.workedMs })) } : {}),
|
|
28
|
+
...clock,
|
|
23
29
|
};
|
|
24
30
|
}
|
|
25
31
|
|
|
26
32
|
function taskOf(ctx: ApiContext): LobbySnapshot["task"] {
|
|
27
33
|
const task = ctx.service.zen().task;
|
|
28
34
|
if (!task) return undefined;
|
|
35
|
+
const work = ctx.service.work?.(task.id);
|
|
29
36
|
return {
|
|
30
37
|
id: task.id,
|
|
31
38
|
title: task.title,
|
|
@@ -33,7 +40,7 @@ function taskOf(ctx: ApiContext): LobbySnapshot["task"] {
|
|
|
33
40
|
...(task.track ? { track: { path: task.track.path, size: task.track.size } } : {}),
|
|
34
41
|
domains: [...(task.domains ?? [])],
|
|
35
42
|
...(task.git ? { git: { branch: task.git.branch, ...(task.git.from ? { from: task.git.from } : {}) } } : {}),
|
|
36
|
-
...planFacts(task),
|
|
43
|
+
...planFacts(task, work),
|
|
37
44
|
};
|
|
38
45
|
}
|
|
39
46
|
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* What the Lobby header and the Tasks calls both read of a task's plan: the
|
|
3
|
-
* worker records as runs,
|
|
4
|
-
* `persistedRuns`) so the
|
|
3
|
+
* worker records as runs, the plan's checklist, the steps being worked on
|
|
4
|
+
* right now and the work time. Kept here (not via `persistedRuns`) so the
|
|
5
|
+
* server never loads the TUI.
|
|
5
6
|
*/
|
|
6
|
-
import { planChecklist, type PlanStep } from "../../pi/plan-checklist.ts";
|
|
7
|
+
import { planChecklist, planSteps, stepWork, type PlanStep } from "../../pi/plan-checklist.ts";
|
|
7
8
|
import type { AgentRun } from "../../schemas/findings.ts";
|
|
8
9
|
import type { Task } from "../../schemas/task.ts";
|
|
10
|
+
import type { WorkProjection } from "../../state/work-time.ts";
|
|
9
11
|
|
|
10
12
|
/** Worker records as runs, the same projection the terminal checklist reads. */
|
|
11
13
|
export function persistedTaskRuns(task: Task): AgentRun[] {
|
|
@@ -23,7 +25,35 @@ export function persistedTaskRuns(task: Task): AgentRun[] {
|
|
|
23
25
|
}));
|
|
24
26
|
}
|
|
25
27
|
|
|
26
|
-
/** The
|
|
27
|
-
|
|
28
|
-
|
|
28
|
+
/** The worker runs under way (from the owner's work file) that have not finished yet, as running runs. */
|
|
29
|
+
function activeRuns(task: Task, work: WorkProjection | undefined): AgentRun[] {
|
|
30
|
+
const finished = new Set((task.workerRuns ?? []).map((record) => record.runId));
|
|
31
|
+
return (work?.active ?? [])
|
|
32
|
+
.filter((run) => !finished.has(run.runId))
|
|
33
|
+
.map((run) => ({ runId: run.runId, taskId: task.id, domain: "backend" as const, role: "worker" as const, status: "running" as const, instruction: run.instruction, output: "", attempts: 1, startedAt: run.startedAt }));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** One step as the lobby shows it: its state, and while a worker is on it, how long they have worked on it. */
|
|
37
|
+
export interface StepView {
|
|
38
|
+
text: string;
|
|
39
|
+
status: PlanStep["status"];
|
|
40
|
+
active: boolean;
|
|
41
|
+
/** Worker time on the step, in ms. */
|
|
42
|
+
workedMs: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The plan's steps with their state, which ones a worker is on right now and
|
|
47
|
+
* the worker time on each. A step being worked on is never done, so steps run
|
|
48
|
+
* side by side all read active.
|
|
49
|
+
*/
|
|
50
|
+
export function stepViews(task: Task, work: WorkProjection | undefined, now = Date.now()): StepView[] {
|
|
51
|
+
if (!task.plan) return [];
|
|
52
|
+
const runs = [...persistedTaskRuns(task), ...activeRuns(task, work)];
|
|
53
|
+
const checklist = planChecklist(task.plan, runs);
|
|
54
|
+
const times = stepWork(planSteps(task.plan), runs, now);
|
|
55
|
+
return checklist.map((step, index) => {
|
|
56
|
+
const time = times[index] ?? { ms: 0, active: false };
|
|
57
|
+
return { text: step.text, status: time.active ? "current" : step.status, active: time.active, workedMs: Math.round(time.ms) };
|
|
58
|
+
});
|
|
29
59
|
}
|