@a-t-h-i/bot-lobby 0.6.3 → 0.6.4
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 +102 -11
- package/package.json +1 -4
- package/prompts/master.md +47 -1
- package/prompts/researcher.md +8 -2
- package/prompts/reviewer.md +42 -0
- package/prompts/worker.md +7 -0
- package/src/ask/dialog.ts +167 -0
- package/src/ask/image.ts +202 -0
- package/src/ask/png.ts +179 -0
- package/src/ask/relay.ts +89 -0
- package/src/ask/state.ts +160 -0
- package/src/ask/tool.ts +126 -0
- package/src/ask/types.ts +55 -0
- package/src/ask/view.ts +159 -0
- package/src/execution/agent-runner.ts +103 -11
- package/src/execution/git.ts +111 -14
- package/src/execution/pi-runner.ts +164 -11
- package/src/index.ts +6 -0
- package/src/lobby/ask.ts +10 -120
- package/src/lobby/layout.ts +27 -8
- package/src/lobby/markdown.ts +36 -6
- package/src/lobby/planner.ts +1 -1
- package/src/lobby/quickfix.ts +21 -0
- package/src/lobby/runtime.ts +14 -36
- package/src/lobby/tabs/home.ts +112 -48
- package/src/lobby/tabs/issues.ts +4 -3
- package/src/lobby/tabs/plan.ts +4 -4
- package/src/lobby/tabs/quickfix.ts +7 -1
- package/src/lobby/tabs/tasks.ts +14 -4
- package/src/lobby/theme.ts +30 -0
- package/src/lobby/view.ts +27 -5
- package/src/master/decisions.ts +1 -1
- package/src/master/master.ts +41 -4
- package/src/master/research.ts +5 -2
- package/src/pi/commands.ts +94 -10
- package/src/pi/events.ts +47 -10
- package/src/pi/quiet.ts +22 -4
- package/src/pi/start-task.ts +8 -2
- package/src/pi/tools.ts +26 -8
- package/src/pi/ui.ts +6 -1
- package/src/pi/zen-metrics.ts +13 -3
- package/src/pi/zen.ts +16 -9
- package/src/roles/reviewer.ts +23 -4
- package/src/roles/worker.ts +18 -0
- package/src/schemas/configuration.ts +4 -0
- package/src/schemas/findings.ts +14 -0
- package/src/schemas/task.ts +21 -0
- package/src/state/budget.ts +274 -0
- package/src/state/changes.ts +231 -0
- package/src/text.ts +28 -2
- package/src/web/extract.ts +332 -0
- package/src/web/fetch.ts +232 -0
- package/src/web/html.ts +183 -0
- package/src/web/read.ts +113 -0
- package/src/web/search.ts +202 -0
- package/src/web/tools.ts +279 -0
- package/src/workflow/workflow.ts +592 -42
package/src/workflow/workflow.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { mkdirSync, realpathSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join, relative } from "node:path";
|
|
2
3
|
import type { BotLobbyConfig, ProfileResolver } from "../schemas/configuration.ts";
|
|
3
|
-
import type { AgentRun, Pushback, ResearchResult, ReviewResult } from "../schemas/findings.ts";
|
|
4
|
+
import type { AgentRun, Pushback, ResearchResult, ReviewResult, WorkerResult } from "../schemas/findings.ts";
|
|
4
5
|
import {
|
|
5
6
|
MAX_RUN_LOG,
|
|
6
7
|
MAX_WORKER_RECORDS,
|
|
@@ -21,12 +22,17 @@ import { appendCompletedTask, appendDecision, applyKnowledge, readFileOr, writeF
|
|
|
21
22
|
import { compactKnowledgeFile, overThreshold } from "../knowledge/compactor.ts";
|
|
22
23
|
import { knowledgeDir, type KnowledgeAgent } from "../knowledge/paths.ts";
|
|
23
24
|
import { writeScratchpad } from "../state/persistence.ts";
|
|
24
|
-
import { spawnPiProcess, type ProcessRunner } from "../execution/pi-runner.ts";
|
|
25
|
+
import { spawnPiProcess, type ProcessRunner, type RelayAsk } from "../execution/pi-runner.ts";
|
|
26
|
+
import { previewDir } from "../ask/relay.ts";
|
|
27
|
+
import type { AskQuestion, AskResult } from "../ask/types.ts";
|
|
25
28
|
import { mapConcurrent } from "../execution/agent-runner.ts";
|
|
26
29
|
import { parseWorkerResult } from "../roles/worker.ts";
|
|
27
30
|
import { autoNote, DESK_TOOLS, DeskSession } from "../desk/session.ts";
|
|
28
31
|
import type { Handover } from "../desk/desk.ts";
|
|
29
|
-
import { readRepositoryDiff } from "../execution/git.ts";
|
|
32
|
+
import { changedFiles, commitBefore, headCommit, readRepositoryDiff } from "../execution/git.ts";
|
|
33
|
+
import { appendChange, explainChanges, provenanceLines, provenanceSummary, readChanges, type FileProvenance } from "../state/changes.ts";
|
|
34
|
+
import { budgetLine, budgetState, formatMinutes, MIN_READ_MS, parseMinutes, qaAllotment, readBudget, readerAllotment, updateBudget, workerAllotment, type Allotment, type BudgetState, type TaskBudget } from "../state/budget.ts";
|
|
35
|
+
import type { AgentTime } from "../execution/agent-runner.ts";
|
|
30
36
|
import {
|
|
31
37
|
loadScoutResults,
|
|
32
38
|
runReviewer,
|
|
@@ -41,7 +47,7 @@ import {
|
|
|
41
47
|
import { researchResultPath, runResearch, type ResearchOutcome, type ResearchRequest } from "../master/research.ts";
|
|
42
48
|
import { assessReconnaissance, completionBlockers, decideReviewLoop, recordDecision } from "../master/decisions.ts";
|
|
43
49
|
import { detectSharedFiles, summarizeOutcomes } from "../master/synthesis.ts";
|
|
44
|
-
import { truncate } from "../text.ts";
|
|
50
|
+
import { tail, truncate } from "../text.ts";
|
|
45
51
|
import { isAutoMode } from "../state/auto.ts";
|
|
46
52
|
import { assertNoPendingApprovals, pendingApprovals, requestApproval, resolveApproval } from "./approvals.ts";
|
|
47
53
|
import { pingApproval } from "../pi/notify.ts";
|
|
@@ -69,6 +75,7 @@ export const ORCHESTRATE_ACTIONS = [
|
|
|
69
75
|
"block",
|
|
70
76
|
"resume",
|
|
71
77
|
"decide",
|
|
78
|
+
"budget",
|
|
72
79
|
"status",
|
|
73
80
|
"cancel",
|
|
74
81
|
] as const;
|
|
@@ -93,7 +100,9 @@ export interface OrchestrateParams {
|
|
|
93
100
|
/** implement: the concrete instruction for the worker. */
|
|
94
101
|
task?: string;
|
|
95
102
|
/** implement: several domains at once, run in parallel through the file desk. */
|
|
96
|
-
assignments?: Array<{ domain: string; task: string }>;
|
|
103
|
+
assignments?: Array<{ domain: string; task: string; minutes?: number }>;
|
|
104
|
+
/** implement: minutes the step may take under the task's time budget; budget: extra minutes to ask the user for. */
|
|
105
|
+
minutes?: number;
|
|
97
106
|
/** knowledge: which persistent file the text belongs to. */
|
|
98
107
|
kind?: KnowledgeKind;
|
|
99
108
|
/** compact: the knowledge file being rewritten. */
|
|
@@ -118,6 +127,8 @@ export interface WorkflowDeps {
|
|
|
118
127
|
onUpdate?: (run: AgentRun) => void;
|
|
119
128
|
ask: (question: string) => Promise<string | undefined>;
|
|
120
129
|
choose: (title: string, options: string[]) => Promise<string | undefined>;
|
|
130
|
+
/** Puts an agent's questions to the user (the questionnaire, `from` naming the agent); absent without a UI. */
|
|
131
|
+
askQuestions?: (questions: AskQuestion[], from: string, signal?: AbortSignal) => Promise<AskResult>;
|
|
121
132
|
notify: (message: string, level?: "info" | "warning" | "error") => void;
|
|
122
133
|
runProcess?: ProcessRunner;
|
|
123
134
|
/** Likely files for scouts and workers, while the classifier's file hints are on. */
|
|
@@ -302,11 +313,14 @@ async function handleScout(task: Task, params: OrchestrateParams, deps: Workflow
|
|
|
302
313
|
if (task.state === "created") transition(task, "clarifying");
|
|
303
314
|
if (task.state === "clarifying") transition(task, "scouting");
|
|
304
315
|
const verifying = task.state === "synthesizing";
|
|
316
|
+
const instruction = params.instruction?.trim() || "Investigate this request and report findings the Master needs.";
|
|
317
|
+
const time = readerTime(task, deps, "SCOUTS", instruction, SCOUT_SHARE, deps.config.scout.timeoutMs, "scouting");
|
|
305
318
|
const outcomes = await runScouts(
|
|
306
319
|
{
|
|
307
320
|
taskId: task.id,
|
|
308
321
|
taskText: taskRequest(task),
|
|
309
|
-
instruction
|
|
322
|
+
instruction,
|
|
323
|
+
...(time ? { time } : {}),
|
|
310
324
|
domains,
|
|
311
325
|
cwd: deps.cwd,
|
|
312
326
|
dataRoots: readDataRoots(deps.root, deps.configDir),
|
|
@@ -320,6 +334,7 @@ async function handleScout(task: Task, params: OrchestrateParams, deps: Workflow
|
|
|
320
334
|
},
|
|
321
335
|
deps.runProcess ?? spawnPiProcess,
|
|
322
336
|
);
|
|
337
|
+
if (time) settleAllotment(task, deps, time.id, "finished");
|
|
323
338
|
if (!verifying) transition(task, "synthesizing");
|
|
324
339
|
const involved = outcomes.filter((outcome) => outcome.usable).map((outcome) => outcome.result.domain);
|
|
325
340
|
task.domains = [...new Set([...task.domains, ...involved])];
|
|
@@ -331,7 +346,7 @@ async function handleScout(task: Task, params: OrchestrateParams, deps: Workflow
|
|
|
331
346
|
const RESEARCH_STATES: TaskState[] = TASK_STATES.filter((state) => !TERMINAL_STATES.includes(state));
|
|
332
347
|
|
|
333
348
|
const RESEARCH_DEGRADED =
|
|
334
|
-
"The researcher is spawned with read-only repository tools plus web_search, fetch_content, source_check
|
|
349
|
+
"The researcher is spawned with read-only repository tools plus bot-lobby's web tools (web_search, fetch_content, source_check, get_search_content). A run without sources usually means the web could not be reached or the search was refused: its tool errors say which (DuckDuckGo, the default, throttles automated searches; BRAVE_API_KEY, TAVILY_API_KEY, EXA_API_KEY or SEARXNG_URL gives a dependable search).";
|
|
335
350
|
|
|
336
351
|
function bulletSection(label: string, items: string[], limit: number): string {
|
|
337
352
|
if (items.length === 0) return "";
|
|
@@ -403,11 +418,13 @@ function researchRequestFor(
|
|
|
403
418
|
domain: Domain,
|
|
404
419
|
instruction: string,
|
|
405
420
|
taskDir: string,
|
|
421
|
+
time?: AgentTime,
|
|
406
422
|
): ResearchRequest {
|
|
407
423
|
return {
|
|
408
424
|
taskId: task.id,
|
|
409
425
|
domain,
|
|
410
426
|
instruction,
|
|
427
|
+
...(time ? { time } : {}),
|
|
411
428
|
config: deps.config,
|
|
412
429
|
profile: deps.profile,
|
|
413
430
|
cwd: deps.cwd,
|
|
@@ -423,7 +440,9 @@ async function handleResearch(task: Task, params: OrchestrateParams, deps: Workf
|
|
|
423
440
|
const instruction = params.instruction?.trim();
|
|
424
441
|
if (!instruction) throw new Error("research requires instruction (the question to investigate)");
|
|
425
442
|
const taskDir = taskDirFor(deps.root, deps.configDir, task.id);
|
|
426
|
-
const
|
|
443
|
+
const time = readerTime(task, deps, "RESEARCH", instruction, RESEARCH_SHARE, deps.config.researcher.timeoutMs ?? deps.config.workflow.agentTimeoutMs, "research");
|
|
444
|
+
const outcome = await runResearch(researchRequestFor(deps, task, domain, instruction, taskDir, time), deps.runProcess ?? spawnPiProcess);
|
|
445
|
+
if (time) settleAllotment(task, deps, time.id, "finished");
|
|
427
446
|
appendResearchLog(taskDir, domain, outcome);
|
|
428
447
|
recordAdvisoryPushbacks(task, [{ pushback: outcome.result.pushback, who: domain }]);
|
|
429
448
|
return researchReport(outcome, researchResultPath(taskDir, domain));
|
|
@@ -592,19 +611,48 @@ function updateScratchpad(deps: WorkflowDeps, task: Task, outcome: WorkerOutcome
|
|
|
592
611
|
writeScratchpad(dir, domain, [existing, entry].filter(Boolean).join("\n\n"), deps.config.knowledge);
|
|
593
612
|
}
|
|
594
613
|
|
|
595
|
-
|
|
614
|
+
/** Plan sections a reader must never lose to a cut: what the work is for, and how it is judged. */
|
|
615
|
+
const KEEP_PLAN_SECTIONS = /objective|goal|acceptance|criteria|testing|tests?\b/i;
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* The plan within `budget` characters: whole when it fits; otherwise the
|
|
619
|
+
* objective, acceptance criteria and testing sections whole (a head cut used
|
|
620
|
+
* to drop them, as they come last), then the others in order while they fit,
|
|
621
|
+
* naming any left out.
|
|
622
|
+
*/
|
|
623
|
+
export function planWithin(plan: string, budget: number): string {
|
|
624
|
+
if (plan.length <= budget) return plan;
|
|
625
|
+
const sections = plan.split(/\n(?=#{1,4}\s)/);
|
|
626
|
+
const keep = sections.map((section) => KEEP_PLAN_SECTIONS.test(section.split("\n")[0] ?? ""));
|
|
627
|
+
let used = sections.reduce((total, section, index) => total + (keep[index] ? section.length + 1 : 0), 0);
|
|
628
|
+
const chosen = sections.map((section, index) => {
|
|
629
|
+
if (keep[index]) return true;
|
|
630
|
+
if (used + section.length + 1 > budget) return false;
|
|
631
|
+
used += section.length + 1;
|
|
632
|
+
return true;
|
|
633
|
+
});
|
|
634
|
+
const left = sections.filter((_, index) => !chosen[index]).map((section) => (section.split("\n")[0] ?? "").replace(/^#+\s*/, "").trim() || "preamble");
|
|
635
|
+
const text = sections.filter((_, index) => chosen[index]).join("\n");
|
|
636
|
+
const note = left.length > 0 ? `\n\n[Left out for length: ${left.join(", ")}. The whole plan is plan.md in the task folder.]` : "";
|
|
637
|
+
return `${truncate(text, budget)}${note}`;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function workerTaskText(task: Task, planBudget = 6000): string {
|
|
596
641
|
return [
|
|
597
642
|
`Requirements: ${taskRequest(task)}`,
|
|
598
643
|
task.proposal ? `Approved objective: ${task.proposal}` : "",
|
|
599
|
-
task.plan ? `Approved plan:\n${
|
|
644
|
+
task.plan ? `Approved plan:\n${planWithin(task.plan, planBudget)}` : "",
|
|
600
645
|
task.amendments.length > 0 ? `User amendments:\n${task.amendments.map((entry) => `- ${entry}`).join("\n")}` : "",
|
|
601
646
|
]
|
|
602
647
|
.filter((line) => line.length > 0)
|
|
603
648
|
.join("\n\n");
|
|
604
649
|
}
|
|
605
650
|
|
|
606
|
-
function workerRequest(deps: WorkflowDeps, task: Task, domain: Domain, instruction: string): WorkerRequest {
|
|
651
|
+
function workerRequest(deps: WorkflowDeps, task: Task, domain: Domain, instruction: string, time?: AgentTime): WorkerRequest {
|
|
652
|
+
const ask = askRelay(task, deps, domain);
|
|
607
653
|
return {
|
|
654
|
+
...(time ? { time } : {}),
|
|
655
|
+
...(ask ? { ask } : {}),
|
|
608
656
|
taskId: task.id,
|
|
609
657
|
domain,
|
|
610
658
|
instruction,
|
|
@@ -621,6 +669,49 @@ function workerRequest(deps: WorkflowDeps, task: Task, domain: Domain, instructi
|
|
|
621
669
|
};
|
|
622
670
|
}
|
|
623
671
|
|
|
672
|
+
/** Agents that may ask the user themselves: the designer, whose choices are the user's to see. */
|
|
673
|
+
const ASKING_DOMAINS: ReadonlySet<Domain> = new Set(["designer"]);
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* The relay for a worker allowed to ask the user: its questions wait their
|
|
677
|
+
* turn behind any other dialog, go to the user with every clock stopped, and
|
|
678
|
+
* the answers become task decisions. Never in auto mode (nobody to ask), and
|
|
679
|
+
* never without a UI.
|
|
680
|
+
*/
|
|
681
|
+
function askRelay(task: Task, deps: WorkflowDeps, domain: Domain): { onAsk: RelayAsk; previews: string } | undefined {
|
|
682
|
+
if (!ASKING_DOMAINS.has(domain) || !deps.askQuestions || isAutoMode(deps.root, deps.configDir, task.id)) return undefined;
|
|
683
|
+
const askQuestions = deps.askQuestions;
|
|
684
|
+
const label = AGENT_LABELS[domain];
|
|
685
|
+
const previews = previewDir(task.id);
|
|
686
|
+
try {
|
|
687
|
+
mkdirSync(previews, { recursive: true });
|
|
688
|
+
} catch {
|
|
689
|
+
// Without the folder it can still ask with Markdown previews.
|
|
690
|
+
}
|
|
691
|
+
const onAsk: RelayAsk = (questions, signal) =>
|
|
692
|
+
oneAtATime(async () => {
|
|
693
|
+
if (signal.aborted) return { answers: [], cancelled: true };
|
|
694
|
+
if (isAutoMode(deps.root, deps.configDir, task.id)) {
|
|
695
|
+
return { answers: [], cancelled: true, globalNote: "Auto mode is on, so nobody can answer: decide with the options you recommend and say in your report what you chose and why." };
|
|
696
|
+
}
|
|
697
|
+
const result = await askQuestions(questions, label, signal);
|
|
698
|
+
recordDecision(task, askedDecision(label, questions, result), domain);
|
|
699
|
+
return result;
|
|
700
|
+
});
|
|
701
|
+
return { onAsk, previews };
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/** An agent's questions and the user's answers, as the task's decision record keeps them. */
|
|
705
|
+
export function askedDecision(label: string, questions: readonly AskQuestion[], result: AskResult): string {
|
|
706
|
+
if (result.answers.length === 0) return `${label} asked the user about ${questions.map((question) => question.header).join(", ")}; they did not answer, so ${label} decides.`;
|
|
707
|
+
const lines = questions.map((question, index) => {
|
|
708
|
+
const answer = result.answers.find((entry) => entry.questionIndex === index);
|
|
709
|
+
const text = !answer ? "not answered" : answer.kind === "multi" ? (answer.selected ?? []).join(", ") : `${answer.answer ?? ""}${answer.kind === "custom" ? " (in their words)" : ""}`;
|
|
710
|
+
return `[${question.header}] ${oneLine(question.question, 120)} → ${oneLine(text, 200)}`;
|
|
711
|
+
});
|
|
712
|
+
return `${label} asked the user: ${lines.join("; ")}`;
|
|
713
|
+
}
|
|
714
|
+
|
|
624
715
|
/** Remember a worker delegation so the checklist replays it after a reload. */
|
|
625
716
|
function recordWorkerRun(task: Task, run: AgentRun): void {
|
|
626
717
|
const record: WorkerRunRecord = {
|
|
@@ -637,6 +728,8 @@ function recordWorkerRun(task: Task, run: AgentRun): void {
|
|
|
637
728
|
interface Assignment {
|
|
638
729
|
domain: Domain;
|
|
639
730
|
instruction: string;
|
|
731
|
+
/** Minutes the oracle gave the step, under a time budget. */
|
|
732
|
+
minutes?: number;
|
|
640
733
|
}
|
|
641
734
|
|
|
642
735
|
/** The delegation as a list: `assignments` for a parallel batch, otherwise the single domain/task. */
|
|
@@ -645,7 +738,7 @@ function parseAssignments(params: OrchestrateParams): Assignment[] {
|
|
|
645
738
|
const list = params.assignments.map((entry) => {
|
|
646
739
|
const instruction = entry.task?.trim();
|
|
647
740
|
if (!instruction) throw new Error("every assignment needs a task (what to implement)");
|
|
648
|
-
return { domain: parseDomain(entry.domain, "implement"), instruction };
|
|
741
|
+
return { domain: parseDomain(entry.domain, "implement"), instruction, ...(entry.minutes ? { minutes: entry.minutes } : {}) };
|
|
649
742
|
});
|
|
650
743
|
const domains = list.map((entry) => entry.domain);
|
|
651
744
|
if (new Set(domains).size !== domains.length) throw new Error("parallel assignments need distinct domains (one worker per domain)");
|
|
@@ -654,18 +747,96 @@ function parseAssignments(params: OrchestrateParams): Assignment[] {
|
|
|
654
747
|
const domain = parseDomain(params.domain, "implement");
|
|
655
748
|
const instruction = params.task?.trim();
|
|
656
749
|
if (!instruction) throw new Error("implement requires task (what to implement)");
|
|
657
|
-
return [{ domain, instruction }];
|
|
750
|
+
return [{ domain, instruction, ...(params.minutes ? { minutes: params.minutes } : {}) }];
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* What the working tree held when this task's agents started: the QA gate
|
|
755
|
+
* reads those files as pre-existing. Taken before the first worker only, so
|
|
756
|
+
* a task already under way when provenance arrived is not misread.
|
|
757
|
+
*/
|
|
758
|
+
async function takeBaseline(task: Task, deps: WorkflowDeps): Promise<void> {
|
|
759
|
+
if (task.baseline || (task.workerRuns?.length ?? 0) > 0) return;
|
|
760
|
+
const [tree, head] = await Promise.all([treeChanges(deps), headCommit(deps.cwd)]);
|
|
761
|
+
task.baseline = { at: new Date().toISOString(), files: tree?.files ?? [], ...(head ? { head } : {}) };
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* The commit the task's work is measured from: HEAD when its first worker
|
|
766
|
+
* started, or, for a task begun before that was recorded, the newest commit
|
|
767
|
+
* before the task was created. Work committed since then is still the task's
|
|
768
|
+
* to review; a diff against HEAD alone showed it as nothing.
|
|
769
|
+
*/
|
|
770
|
+
async function reviewBase(task: Task, deps: WorkflowDeps): Promise<string | undefined> {
|
|
771
|
+
return task.baseline?.head ?? (await commitBefore(deps.cwd, task.createdAt));
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/** bot-lobby's own records, relative to the working folder, when they sit inside it. */
|
|
775
|
+
function ownRecords(deps: WorkflowDeps): string[] {
|
|
776
|
+
const own = relative(deps.cwd, dataRoot(deps.root, deps.configDir));
|
|
777
|
+
return own && !own.startsWith("..") && !isAbsolute(own) ? [own.split("\\").join("/")] : [];
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/** The working tree's changed files, without bot-lobby's own records (its data folder may sit untracked in the project). */
|
|
781
|
+
async function treeChanges(deps: WorkflowDeps, base?: string): Promise<{ top: string; files: string[] } | undefined> {
|
|
782
|
+
const tree = await changedFiles(deps.cwd, base);
|
|
783
|
+
if (!tree) return undefined;
|
|
784
|
+
let data = dataRoot(deps.root, deps.configDir);
|
|
785
|
+
try {
|
|
786
|
+
data = realpathSync(data);
|
|
787
|
+
} catch {
|
|
788
|
+
// Not created yet: nothing of it can be in the tree.
|
|
789
|
+
}
|
|
790
|
+
const own = relative(tree.top, data);
|
|
791
|
+
if (!own || own.startsWith("..") || isAbsolute(own)) return tree;
|
|
792
|
+
const prefix = `${own.split("\\").join("/")}/`;
|
|
793
|
+
return { top: tree.top, files: tree.files.filter((file) => !file.startsWith(prefix)) };
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/** Every changed file of the working tree, explained for this task; undefined without a baseline or git. */
|
|
797
|
+
async function changeProvenance(deps: WorkflowDeps, task: Task, base?: string): Promise<FileProvenance[] | undefined> {
|
|
798
|
+
if (!task.baseline) return undefined;
|
|
799
|
+
const tree = await treeChanges(deps, base);
|
|
800
|
+
if (!tree) return undefined;
|
|
801
|
+
return explainChanges(task, tree.files, tree.top, readChanges(deps.root, deps.configDir, task.createdAt));
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/** Who changed the tree, for the Master: counts, and what the changes that are not this task's mean for it. */
|
|
805
|
+
function provenanceNote(files: readonly FileProvenance[] | undefined): string {
|
|
806
|
+
if (!files || files.length === 0) return "";
|
|
807
|
+
const kinds = new Set(files.flatMap((file) => file.kinds));
|
|
808
|
+
return [
|
|
809
|
+
`Changed files: ${provenanceSummary(files)}.`,
|
|
810
|
+
kinds.has("quickfix") ? "The user asked for the quick fixes directly: never revert them or send them back as fixes." : "",
|
|
811
|
+
kinds.has("pre-existing") || kinds.has("other-task") ? "Pre-existing changes and other tasks' are not this task's to review or revert." : "",
|
|
812
|
+
kinds.has("unattributed") ? "No agent recorded the unattributed edits: ask the user before counting them in or reverting them." : "",
|
|
813
|
+
]
|
|
814
|
+
.filter((line) => line.length > 0)
|
|
815
|
+
.join(" ");
|
|
658
816
|
}
|
|
659
817
|
|
|
660
818
|
/** Record one worker's outcome on the task and return its report for the Master. */
|
|
661
|
-
function absorbWorkerOutcome(task: Task, deps: WorkflowDeps, outcome: WorkerOutcome): string {
|
|
819
|
+
function absorbWorkerOutcome(task: Task, deps: WorkflowDeps, outcome: WorkerOutcome, time?: WorkerTime): string {
|
|
662
820
|
const domain = outcome.result.domain;
|
|
663
821
|
recordWorkerRun(task, outcome.run);
|
|
822
|
+
if (time) settleAllotment(task, deps, time.id, stoppedForTime(outcome) ? "out of time" : outcome.run.status === "success" ? "finished" : "stopped");
|
|
823
|
+
appendChange(deps.root, deps.configDir, {
|
|
824
|
+
source: "worker",
|
|
825
|
+
id: outcome.run.runId,
|
|
826
|
+
taskId: task.id,
|
|
827
|
+
domain,
|
|
828
|
+
what: (outcome.run.instruction ?? "").split("\n").find((line) => line.trim())?.trim() ?? domain,
|
|
829
|
+
files: outcome.run.edited ?? [],
|
|
830
|
+
startedAt: outcome.run.startedAt,
|
|
831
|
+
finishedAt: outcome.run.finishedAt ?? new Date().toISOString(),
|
|
832
|
+
status: outcome.run.status,
|
|
833
|
+
});
|
|
664
834
|
const approvals = recordWorkerApprovals(task, outcome, deps.config, isAutoMode(deps.root, deps.configDir, task.id));
|
|
665
835
|
const pushback = recordPushback(task, outcome);
|
|
666
836
|
task.blockers = [...task.blockers.filter((blocker) => blocker.domain !== domain), ...outcome.result.blockers];
|
|
667
837
|
updateScratchpad(deps, task, outcome);
|
|
668
|
-
|
|
838
|
+
const spent = timeReport(outcome);
|
|
839
|
+
return spent ? `${workerReport(outcome, approvals, pushback)}\n${spent}` : workerReport(outcome, approvals, pushback);
|
|
669
840
|
}
|
|
670
841
|
|
|
671
842
|
async function handleImplement(task: Task, params: OrchestrateParams, deps: WorkflowDeps): Promise<string> {
|
|
@@ -673,13 +844,23 @@ async function handleImplement(task: Task, params: OrchestrateParams, deps: Work
|
|
|
673
844
|
const assignments = parseAssignments(params);
|
|
674
845
|
for (const { domain } of assignments) assertNoPendingApprovals(task, domain);
|
|
675
846
|
for (const { domain } of assignments) if (!task.domains.includes(domain)) task.domains.push(domain);
|
|
847
|
+
// Under a time budget every step is given its share before any starts; a spent budget starts none.
|
|
848
|
+
const times = workerTimes(task, deps, assignments);
|
|
676
849
|
if (task.state !== "implementing") transition(task, "implementing");
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
850
|
+
await takeBaseline(task, deps);
|
|
851
|
+
let report: string;
|
|
852
|
+
try {
|
|
853
|
+
if (assignments.length === 1) {
|
|
854
|
+
const { domain, instruction } = assignments[0]!;
|
|
855
|
+
const outcome = await runWorker(workerRequest(deps, task, domain, instruction, times.get(domain)), deps.runProcess ?? spawnPiProcess);
|
|
856
|
+
report = absorbWorkerOutcome(task, deps, outcome, times.get(domain));
|
|
857
|
+
} else report = await runParallelWorkers(task, deps, assignments, times);
|
|
858
|
+
} finally {
|
|
859
|
+
// A step that never reported (the call failed) does not stay "running" in the budget.
|
|
860
|
+
for (const time of times.values()) settleAllotment(task, deps, time.id, "stopped");
|
|
681
861
|
}
|
|
682
|
-
|
|
862
|
+
const note = provenanceNote(await changeProvenance(deps, task, task.baseline?.head));
|
|
863
|
+
return note ? `${report}\n\n${note}` : report;
|
|
683
864
|
}
|
|
684
865
|
|
|
685
866
|
/**
|
|
@@ -687,14 +868,14 @@ async function handleImplement(task: Task, params: OrchestrateParams, deps: Work
|
|
|
687
868
|
* before editing it, queues for a busy one, and hands it over with a note; a
|
|
688
869
|
* worker that finishes hands over whatever it still holds automatically.
|
|
689
870
|
*/
|
|
690
|
-
async function runParallelWorkers(task: Task, deps: WorkflowDeps, assignments: Assignment[]): Promise<string> {
|
|
871
|
+
async function runParallelWorkers(task: Task, deps: WorkflowDeps, assignments: Assignment[], times: ReadonlyMap<Domain, WorkerTime>): Promise<string> {
|
|
691
872
|
const session = new DeskSession({ cwd: deps.cwd });
|
|
692
873
|
await session.open();
|
|
693
874
|
let outcomes: WorkerOutcome[];
|
|
694
875
|
let unenforced: Domain[];
|
|
695
876
|
try {
|
|
696
877
|
outcomes = await mapConcurrent(assignments, deps.config.workflow.maxParallelWorkers, ({ domain, instruction }) => {
|
|
697
|
-
const request = workerRequest(deps, task, domain, instruction);
|
|
878
|
+
const request = workerRequest(deps, task, domain, instruction, times.get(domain));
|
|
698
879
|
request.agent = {
|
|
699
880
|
env: session.env(domain),
|
|
700
881
|
extraTools: DESK_TOOLS,
|
|
@@ -710,7 +891,7 @@ async function runParallelWorkers(task: Task, deps: WorkflowDeps, assignments: A
|
|
|
710
891
|
} finally {
|
|
711
892
|
await session.close();
|
|
712
893
|
}
|
|
713
|
-
const reports = outcomes.map((outcome) => absorbWorkerOutcome(task, deps, outcome));
|
|
894
|
+
const reports = outcomes.map((outcome) => absorbWorkerOutcome(task, deps, outcome, times.get(outcome.result.domain)));
|
|
714
895
|
return [
|
|
715
896
|
`Parallel batch: ${assignments.map((entry) => entry.domain).join(", ")}.`,
|
|
716
897
|
...reports,
|
|
@@ -765,28 +946,52 @@ function recordReview(task: Task, domain: Domain, result: ReviewResult): void {
|
|
|
765
946
|
createdAt: new Date().toISOString(),
|
|
766
947
|
});
|
|
767
948
|
}
|
|
768
|
-
|
|
949
|
+
/** Each domain's scratchpad, its newest entries kept when it is long (the latest fix round matters most). */
|
|
950
|
+
function allScratchpads(deps: WorkflowDeps, task: Task, perDomain = 1500): string {
|
|
769
951
|
return (["designer", "backend", "qa"] as Domain[])
|
|
770
|
-
.map((domain) => scratchpadSummary(deps, task, domain).trim())
|
|
952
|
+
.map((domain) => tail(scratchpadSummary(deps, task, domain).trim(), perDomain))
|
|
771
953
|
.filter((text) => text.length > 0)
|
|
772
954
|
.join("\n\n---\n\n");
|
|
773
955
|
}
|
|
774
956
|
|
|
957
|
+
/**
|
|
958
|
+
* What the last QA round asked for, so the next one verifies it instead of
|
|
959
|
+
* reviewing everything from scratch (and finding new things each time).
|
|
960
|
+
*/
|
|
961
|
+
function previousRound(task: Task): string {
|
|
962
|
+
const rounds = task.reviewRecords.filter((record) => record.domain === "qa");
|
|
963
|
+
const last = rounds.at(-1);
|
|
964
|
+
if (!last || last.verdict === "pass") return "";
|
|
965
|
+
const asks = [
|
|
966
|
+
...last.findings.filter((finding) => finding.severity === "critical" || finding.severity === "major").map((finding) => `[${finding.severity}] ${finding.text}`),
|
|
967
|
+
...last.requiredChanges,
|
|
968
|
+
];
|
|
969
|
+
if (asks.length === 0) return "";
|
|
970
|
+
return [
|
|
971
|
+
`This is QA round ${rounds.length + 1}. Round ${rounds.length} (${last.verdict.toUpperCase()}) asked for:`,
|
|
972
|
+
...asks.slice(0, 20).map((ask) => `- ${truncate(ask, 300)}`),
|
|
973
|
+
"Verify each of these first and say which are addressed. Do not start the review over: a new blocking finding must be critical or major (a problem the fixes introduced, or an unmet acceptance criterion); anything else goes under Optional Improvements.",
|
|
974
|
+
].join("\n");
|
|
975
|
+
}
|
|
976
|
+
|
|
775
977
|
const QA_INSTRUCTION = [
|
|
776
978
|
"Run the QA quality gate for the completed feature.",
|
|
777
979
|
"Verify requirements, acceptance criteria, regression risk, edge cases, security, accessibility,",
|
|
778
980
|
"UX, reliability, and tests. Passing automated tests alone is not acceptance.",
|
|
779
981
|
].join(" ");
|
|
780
982
|
|
|
781
|
-
/** The QA gate looks at every domain's work, not just one worker's diff. */
|
|
782
|
-
function qaRequest(deps: WorkflowDeps, task: Task, diff: string, instruction?: string): ReviewerRequest {
|
|
983
|
+
/** The QA gate looks at every domain's work, not just one worker's diff, knowing who changed each file. */
|
|
984
|
+
function qaRequest(deps: WorkflowDeps, task: Task, diff: string, instruction?: string, provenance?: readonly FileProvenance[]): ReviewerRequest {
|
|
985
|
+
const rounds = previousRound(task);
|
|
783
986
|
return {
|
|
784
987
|
taskId: task.id,
|
|
785
988
|
domain: "qa",
|
|
786
|
-
taskText:
|
|
989
|
+
taskText: workerTaskText(task, 9000),
|
|
787
990
|
workerSummary: allScratchpads(deps, task),
|
|
991
|
+
...(rounds ? { previousRound: rounds } : {}),
|
|
788
992
|
scoutOutcomes: loadScoutResults(taskReadDirs(deps.root, deps.configDir, task.id), [...new Set<Domain>(["qa", ...task.domains])]),
|
|
789
993
|
diff,
|
|
994
|
+
...(provenance && provenance.length > 0 ? { provenance: provenanceLines(provenance) } : {}),
|
|
790
995
|
instruction: instruction?.trim() || QA_INSTRUCTION,
|
|
791
996
|
cwd: deps.cwd,
|
|
792
997
|
dataRoots: readDataRoots(deps.root, deps.configDir),
|
|
@@ -797,22 +1002,30 @@ function qaRequest(deps: WorkflowDeps, task: Task, diff: string, instruction?: s
|
|
|
797
1002
|
};
|
|
798
1003
|
}
|
|
799
1004
|
|
|
800
|
-
|
|
1005
|
+
/** What the loop does after a QA round: finish, fix and review again, stop, or (the user's call) accept the work as it is. */
|
|
1006
|
+
type LoopDecision = "accept" | "iterate" | "blocked" | "waived";
|
|
1007
|
+
|
|
1008
|
+
function qaReport(outcome: ReviewerOutcome, decision: LoopDecision, provenance?: readonly FileProvenance[]): string {
|
|
801
1009
|
const { result, run, issues } = outcome;
|
|
1010
|
+
const passed = result.verdict === "pass";
|
|
802
1011
|
return [
|
|
803
|
-
`QA gate: ${result.verdict.toUpperCase()} (run ${run.status}${run.error ? `: ${run.error}` : ""})`,
|
|
1012
|
+
`QA gate: ${result.verdict.toUpperCase()} (run ${run.status}${run.error ? `: ${run.error}` : ""})${result.relaxed ? " — only minor findings, which never hold the gate" : ""}`,
|
|
804
1013
|
result.findings.length > 0
|
|
805
1014
|
? `Findings:\n${result.findings.map((finding) => `- [${finding.severity}] ${truncate(finding.text, 300)}`).join("\n")}`
|
|
806
1015
|
: "",
|
|
807
1016
|
result.requiredChanges.length > 0
|
|
808
|
-
?
|
|
1017
|
+
? `${passed ? "Follow-ups (not blocking; mention them to the user, do not start a fix round for them)" : "Required changes"}:\n${result.requiredChanges.map((change) => `- ${truncate(change, 300)}`).join("\n")}`
|
|
809
1018
|
: "",
|
|
810
1019
|
decision === "accept"
|
|
811
1020
|
? "The QA gate passed. Record any distilled knowledge, then call action=complete."
|
|
812
|
-
:
|
|
813
|
-
|
|
1021
|
+
: decision === "waived"
|
|
1022
|
+
? "The user accepted the work as it is, without a QA pass. Call action=complete now with a short summary that names what QA still asked for."
|
|
1023
|
+
: decision === "iterate"
|
|
1024
|
+
? "The QA gate did not pass: delegate the required changes to the owning domain, then re-run action=qa."
|
|
1025
|
+
: "The review limit is reached and the user did not accept the work: mark the task blocked and tell the user what QA still asks for. They can accept it with /bot-lobby accept.",
|
|
814
1026
|
result.pushback ? pushbackLine(result.pushback, "qa") : "",
|
|
815
1027
|
issues.length > 0 ? `Issues: ${issues.join("; ")}` : "",
|
|
1028
|
+
provenanceNote(provenance),
|
|
816
1029
|
]
|
|
817
1030
|
.filter((line) => line.length > 0)
|
|
818
1031
|
.join("\n");
|
|
@@ -823,14 +1036,87 @@ async function handleQa(task: Task, params: OrchestrateParams, deps: WorkflowDep
|
|
|
823
1036
|
if (task.state !== "reviewing") transition(task, "reviewing");
|
|
824
1037
|
const iterations = (task.reviewIterations?.qa ?? 0) + 1;
|
|
825
1038
|
task.reviewIterations = { qa: iterations };
|
|
826
|
-
const
|
|
827
|
-
const
|
|
1039
|
+
const time = qaTime(task, deps);
|
|
1040
|
+
const base = await reviewBase(task, deps);
|
|
1041
|
+
const [diff, provenance] = await Promise.all([
|
|
1042
|
+
readRepositoryDiff(deps.cwd, { ...(base ? { base } : {}), exclude: ownRecords(deps) }),
|
|
1043
|
+
changeProvenance(deps, task, base),
|
|
1044
|
+
]);
|
|
1045
|
+
const outcome = await runReviewer({ ...qaRequest(deps, task, diff, params.task, provenance), ...(time ? { time } : {}) }, deps.runProcess ?? spawnPiProcess);
|
|
1046
|
+
if (time) settleAllotment(task, deps, time.id, "finished");
|
|
828
1047
|
task.qaVerdict = outcome.result.verdict;
|
|
829
1048
|
recordReview(task, "qa", outcome.result);
|
|
830
1049
|
recordAdvisoryPushbacks(task, [{ pushback: outcome.result.pushback, who: "qa" }]);
|
|
831
|
-
|
|
1050
|
+
if (outcome.result.relaxed) recordDecision(task, `QA round ${iterations} passed: ${outcome.result.relaxed}.`, "qa");
|
|
1051
|
+
let decision: LoopDecision = decideReviewLoop(outcome.result.verdict, iterations, reviewLimit(task, deps));
|
|
1052
|
+
if (decision === "blocked") decision = await askAtReviewLimit(task, outcome.result, iterations, deps);
|
|
832
1053
|
if (decision === "accept") task.blockers = task.blockers.filter((blocker) => blocker.domain !== "qa");
|
|
833
|
-
return qaReport(outcome, decision);
|
|
1054
|
+
return qaReport(outcome, decision, provenance);
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
/** Review rounds allowed: the configured limit plus any the user granted. */
|
|
1058
|
+
function reviewLimit(task: Task, deps: WorkflowDeps): number {
|
|
1059
|
+
return deps.config.workflow.maxReviewIterations + (task.extraReviewRounds ?? 0);
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
/** What QA still asks for, one line each: its blocking findings and required changes. */
|
|
1063
|
+
function openAsks(result: Pick<ReviewResult, "findings" | "requiredChanges">): string[] {
|
|
1064
|
+
return [
|
|
1065
|
+
...result.findings.filter((finding) => finding.severity === "critical" || finding.severity === "major").map((finding) => `[${finding.severity}] ${finding.text}`),
|
|
1066
|
+
...result.requiredChanges,
|
|
1067
|
+
];
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
const ACCEPT_WORK = "Accept the work as it is and complete the task";
|
|
1071
|
+
const ONE_MORE_ROUND = "Run one more fix round";
|
|
1072
|
+
|
|
1073
|
+
/**
|
|
1074
|
+
* The review limit is reached (or QA says BLOCKED): the user decides, not the
|
|
1075
|
+
* loop. They can accept the work as it is, grant one more fix round, or leave
|
|
1076
|
+
* the task blocked. Nobody is asked in auto mode or without a UI: it blocks.
|
|
1077
|
+
*/
|
|
1078
|
+
async function askAtReviewLimit(task: Task, result: ReviewResult, iterations: number, deps: WorkflowDeps): Promise<LoopDecision> {
|
|
1079
|
+
if (isAutoMode(deps.root, deps.configDir, task.id)) return "blocked";
|
|
1080
|
+
const asks = openAsks(result);
|
|
1081
|
+
const checks = result.verification.split("\n").map((line) => line.trim()).filter((line) => line.startsWith("-")).slice(0, 4);
|
|
1082
|
+
const choice = await deps.choose(
|
|
1083
|
+
[
|
|
1084
|
+
`QA has not passed ${task.id} after ${iterations} round${iterations === 1 ? "" : "s"} (this one: ${result.verdict.toUpperCase()}).`,
|
|
1085
|
+
asks.length > 0 ? `It still asks for:\n${asks.slice(0, 8).map((ask) => `- ${truncate(ask, 200)}`).join("\n")}` : "",
|
|
1086
|
+
checks.length > 0 ? `Its checks:\n${checks.map((line) => truncate(line, 160)).join("\n")}` : "",
|
|
1087
|
+
].filter(Boolean).join("\n\n"),
|
|
1088
|
+
[ACCEPT_WORK, ONE_MORE_ROUND, "Leave the task blocked"],
|
|
1089
|
+
);
|
|
1090
|
+
if (choice === ACCEPT_WORK) {
|
|
1091
|
+
waiveQa(task, asks, `after ${iterations} QA round${iterations === 1 ? "" : "s"}`);
|
|
1092
|
+
return "waived";
|
|
1093
|
+
}
|
|
1094
|
+
if (choice === ONE_MORE_ROUND) {
|
|
1095
|
+
task.extraReviewRounds = (task.extraReviewRounds ?? 0) + 1;
|
|
1096
|
+
recordDecision(task, `The user granted one more QA round after ${iterations}.`);
|
|
1097
|
+
return "iterate";
|
|
1098
|
+
}
|
|
1099
|
+
return "blocked";
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
/**
|
|
1103
|
+
* The user accepts the work as it stands: the QA gate is waived, blockers are
|
|
1104
|
+
* cleared, and a blocked task returns to review so it can complete. Only the
|
|
1105
|
+
* user's own choice (a dialog or /bot-lobby accept) ever gets here.
|
|
1106
|
+
*/
|
|
1107
|
+
export function waiveQa(task: Task, open: readonly string[], why: string): void {
|
|
1108
|
+
task.qaWaiver = { at: new Date().toISOString(), open: open.map((ask) => truncate(ask, 300)) };
|
|
1109
|
+
const cleared = task.blockers.map((blocker) => blocker.reason);
|
|
1110
|
+
task.blockers = [];
|
|
1111
|
+
if (task.state === "blocked") transition(task, "implementing");
|
|
1112
|
+
if (task.state === "implementing") transition(task, "reviewing");
|
|
1113
|
+
recordDecision(task, `The user accepted the work without a QA pass (${why}).${open.length > 0 ? ` QA still asked for: ${open.map((ask) => truncate(ask, 160)).join("; ")}.` : ""}${cleared.length > 0 ? ` Cleared blockers: ${cleared.join("; ")}.` : ""}`);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/** The last QA round's open asks, for a waiver made outside the loop. */
|
|
1117
|
+
export function lastQaAsks(task: Task): string[] {
|
|
1118
|
+
const last = task.reviewRecords.filter((record) => record.domain === "qa").at(-1);
|
|
1119
|
+
return last && last.verdict !== "pass" ? openAsks(last) : [];
|
|
834
1120
|
}
|
|
835
1121
|
|
|
836
1122
|
/**
|
|
@@ -872,8 +1158,11 @@ function handleCompact(task: Task, params: OrchestrateParams, deps: WorkflowDeps
|
|
|
872
1158
|
}
|
|
873
1159
|
|
|
874
1160
|
/** §63: record history, drop scratchpads, then mark the task completed. */
|
|
875
|
-
function handleComplete(task: Task, params: OrchestrateParams, deps: WorkflowDeps): string {
|
|
876
|
-
requireState(task, ["reviewing"]);
|
|
1161
|
+
async function handleComplete(task: Task, params: OrchestrateParams, deps: WorkflowDeps): Promise<string> {
|
|
1162
|
+
requireState(task, ["reviewing", "blocked"]);
|
|
1163
|
+
// Without a QA pass only the user can let the task finish: they are asked, never overruled.
|
|
1164
|
+
if (task.qaVerdict !== "pass" && !task.qaWaiver) await offerAcceptance(task, deps);
|
|
1165
|
+
if (task.state === "blocked") throw new Error("cannot complete: the task is blocked, and the user did not accept its work as it is");
|
|
877
1166
|
const blockers = completionBlockers(task, pendingApprovals(task).length);
|
|
878
1167
|
if (blockers.length > 0) throw new Error(`cannot complete: ${blockers.join("; ")}`);
|
|
879
1168
|
const summary = params.text?.trim() || task.proposal || task.title;
|
|
@@ -890,6 +1179,21 @@ function handleComplete(task: Task, params: OrchestrateParams, deps: WorkflowDep
|
|
|
890
1179
|
return `Task ${task.id} completed. History recorded and temporary scratchpads removed.${advice}`;
|
|
891
1180
|
}
|
|
892
1181
|
|
|
1182
|
+
/** Asked when the oracle completes a task QA has not passed (the user told it to finish, say). */
|
|
1183
|
+
async function offerAcceptance(task: Task, deps: WorkflowDeps): Promise<void> {
|
|
1184
|
+
if (isAutoMode(deps.root, deps.configDir, task.id)) return;
|
|
1185
|
+
const asks = lastQaAsks(task);
|
|
1186
|
+
const rounds = task.reviewIterations.qa;
|
|
1187
|
+
const choice = await deps.choose(
|
|
1188
|
+
[
|
|
1189
|
+
`Complete ${task.id} without a QA pass? ${rounds > 0 ? `QA ran ${rounds} round${rounds === 1 ? "" : "s"}; the last said ${(task.qaVerdict ?? "nothing").toUpperCase()}.` : "QA has not run."}`,
|
|
1190
|
+
asks.length > 0 ? `It still asks for:\n${asks.slice(0, 8).map((ask) => `- ${truncate(ask, 200)}`).join("\n")}` : "",
|
|
1191
|
+
].filter(Boolean).join("\n\n"),
|
|
1192
|
+
["Complete it anyway", "Not yet"],
|
|
1193
|
+
);
|
|
1194
|
+
if (choice === "Complete it anyway") waiveQa(task, asks, "when the oracle completed it");
|
|
1195
|
+
}
|
|
1196
|
+
|
|
893
1197
|
function flushDecisions(deps: WorkflowDeps, task: Task): void {
|
|
894
1198
|
const root = dataRoot(deps.root, deps.configDir);
|
|
895
1199
|
for (const decision of task.decisions) {
|
|
@@ -936,6 +1240,251 @@ function handleCancel(task: Task): string {
|
|
|
936
1240
|
return `Task ${task.id} abandoned. Its scratchpad is kept until the task is formally resolved.`;
|
|
937
1241
|
}
|
|
938
1242
|
|
|
1243
|
+
/* ------------------------------------------------------------ time budget */
|
|
1244
|
+
|
|
1245
|
+
/** Shares of what is left that a scout batch and the researcher are given (within their configured limits). */
|
|
1246
|
+
const SCOUT_SHARE = 0.1;
|
|
1247
|
+
const RESEARCH_SHARE = 0.15;
|
|
1248
|
+
/** Minutes a worker out of time is taken to ask for when its report names none. */
|
|
1249
|
+
const DEFAULT_MORE_MINUTES = 10;
|
|
1250
|
+
|
|
1251
|
+
/** A delegation's time, with the allotment it is recorded under. */
|
|
1252
|
+
type WorkerTime = AgentTime & { id: string };
|
|
1253
|
+
|
|
1254
|
+
/** The task's budget and where it stands, when it has one. */
|
|
1255
|
+
function budgetFor(task: Task, deps: WorkflowDeps): { budget: TaskBudget; state: BudgetState } | undefined {
|
|
1256
|
+
const budget = readBudget(deps.root, deps.configDir, task.id);
|
|
1257
|
+
return budget ? { budget, state: budgetState(task.id, budget, task.qaVerdict === "pass") } : undefined;
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
/** No new work starts once the budget is spent: the oracle asks the user for more, or wraps up. */
|
|
1261
|
+
function budgetSpent(state: BudgetState, what: string): Error {
|
|
1262
|
+
const reserve = state.reserveMs > 0 ? `, ${formatMinutes(state.reserveMs)} of it kept for the QA gate` : "";
|
|
1263
|
+
const wrap = state.reserveMs >= MIN_READ_MS ? "run action=qa on what is done" : "complete or block with what is done";
|
|
1264
|
+
return new Error(`the task's time budget has no room for ${what}: ${formatMinutes(state.usedMs)} of ${formatMinutes(state.totalMs)} used, ${formatMinutes(state.leftMs)} left${reserve}. Ask the user for more time with action=budget (minutes and reason), or wrap up: ${wrap}.`);
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
/** What an agent is told about its time. */
|
|
1268
|
+
function timeNote(allotMs: number, state: BudgetState, worker: boolean): string {
|
|
1269
|
+
return [
|
|
1270
|
+
"## Time",
|
|
1271
|
+
`You have ${formatMinutes(allotMs)} for this ${worker ? "step" : "work"}; the task has ${formatMinutes(state.leftMs)} of its ${formatMinutes(state.totalMs)} left.`,
|
|
1272
|
+
worker
|
|
1273
|
+
? "At about 75% you get a heads-up. When the time is up you are asked to stop and report where you left off (`## Left Off`) and how much more you need (`## More Time`); the user decides whether you get it. Land the most important part first, and keep every file consistent as you go."
|
|
1274
|
+
: "When the time is up you are asked to stop and report what you have, so cover the most important questions first.",
|
|
1275
|
+
].join("\n");
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
function recordAllotment(task: Task, deps: WorkflowDeps, entry: Omit<Allotment, "startedAt" | "granted">): void {
|
|
1279
|
+
updateBudget(deps.root, deps.configDir, task.id, (budget) => {
|
|
1280
|
+
budget.allotments.push({ ...entry, granted: 0, startedAt: new Date().toISOString() });
|
|
1281
|
+
});
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
function settleAllotment(task: Task, deps: WorkflowDeps, id: string, outcome: NonNullable<Allotment["outcome"]>): void {
|
|
1285
|
+
updateBudget(deps.root, deps.configDir, task.id, (budget) => {
|
|
1286
|
+
const entry = budget.allotments.find((allotment) => allotment.id === id);
|
|
1287
|
+
if (entry && !entry.endedAt) Object.assign(entry, { endedAt: new Date().toISOString(), outcome });
|
|
1288
|
+
});
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
function oneLine(text: string, max: number): string {
|
|
1292
|
+
return truncate(text.replace(/\s+/g, " ").trim(), max).replace(/\n\[\.\.\.\d+ characters omitted\]$/, "…");
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
/** A read-only batch's time (scouts, the researcher): a share of what is left, within its configured limit. */
|
|
1296
|
+
function readerTime(task: Task, deps: WorkflowDeps, who: string, what: string, share: number, limitMs: number, label: string): WorkerTime | undefined {
|
|
1297
|
+
const now = budgetFor(task, deps);
|
|
1298
|
+
if (!now) return undefined;
|
|
1299
|
+
const ms = readerAllotment(now.state, share, limitMs);
|
|
1300
|
+
if (ms === undefined) throw budgetSpent(now.state, label);
|
|
1301
|
+
const id = `${who.toLowerCase()}-${Date.now().toString(36)}`;
|
|
1302
|
+
recordAllotment(task, deps, { id, who, what: oneLine(what, 120), minutes: Math.round(ms / 60_000) });
|
|
1303
|
+
return { id, endsAt: Date.now() + ms, allotMs: ms, note: timeNote(ms, now.state, false) };
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
/** The QA gate's time: its reserve at least, never more than is left. */
|
|
1307
|
+
function qaTime(task: Task, deps: WorkflowDeps): WorkerTime | undefined {
|
|
1308
|
+
const now = budgetFor(task, deps);
|
|
1309
|
+
if (!now) return undefined;
|
|
1310
|
+
const ms = qaAllotment(now.state, deps.config.agents.qa.timeoutMs ?? deps.config.workflow.agentTimeoutMs);
|
|
1311
|
+
if (ms === undefined) throw budgetSpent(now.state, "the QA gate");
|
|
1312
|
+
const id = `qa-${Date.now().toString(36)}`;
|
|
1313
|
+
recordAllotment(task, deps, { id, who: "QA gate", what: "review the task's work", minutes: Math.round(ms / 60_000) });
|
|
1314
|
+
return { id, endsAt: Date.now() + ms, allotMs: ms, note: timeNote(ms, now.state, false) };
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
/** Plan domains still to build, the batch counted once: what is left before the QA reserve is split across them by default. */
|
|
1318
|
+
function openSlots(task: Task, batch: readonly Domain[]): number {
|
|
1319
|
+
const built = new Set((task.workerRuns ?? []).filter((run) => run.status === "success").map((run) => run.domain));
|
|
1320
|
+
return task.domains.filter((domain) => !built.has(domain) && !batch.includes(domain)).length + 1;
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
/**
|
|
1324
|
+
* Each step's time, given before any starts: the minutes the oracle asked
|
|
1325
|
+
* for (it divides by scope) or an even share, never past what is left before
|
|
1326
|
+
* the QA gate's reserve. A spent budget starts nothing.
|
|
1327
|
+
*/
|
|
1328
|
+
function workerTimes(task: Task, deps: WorkflowDeps, assignments: readonly Assignment[]): Map<Domain, WorkerTime> {
|
|
1329
|
+
const times = new Map<Domain, WorkerTime>();
|
|
1330
|
+
const now = budgetFor(task, deps);
|
|
1331
|
+
if (!now) return times;
|
|
1332
|
+
const slots = openSlots(task, assignments.map((entry) => entry.domain));
|
|
1333
|
+
for (const { domain, instruction, minutes } of assignments) {
|
|
1334
|
+
const given = workerAllotment(now.state, minutes, slots);
|
|
1335
|
+
if (!given) throw budgetSpent(now.state, `the ${domain} step`);
|
|
1336
|
+
const id = `${domain}-${Date.now().toString(36)}`;
|
|
1337
|
+
recordAllotment(task, deps, { id, who: AGENT_LABELS[domain], what: oneLine(instruction, 120), minutes: Math.round(given.ms / 60_000) });
|
|
1338
|
+
if (given.note) recordDecision(task, `${AGENT_LABELS[domain]}'s step: ${given.note}.`);
|
|
1339
|
+
times.set(domain, { id, endsAt: Date.now() + given.ms, allotMs: given.ms, note: timeNote(given.ms, now.state, true), onTimeUp: moreTimeFor(task, deps, domain, instruction, id) });
|
|
1340
|
+
}
|
|
1341
|
+
return times;
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
const AGENT_LABELS: Record<Domain, string> = { backend: "DEV", designer: "DESIGN", qa: "QA" };
|
|
1345
|
+
|
|
1346
|
+
/** Out-of-time questions one at a time, even when parallel workers run out together. */
|
|
1347
|
+
let asking: Promise<unknown> = Promise.resolve();
|
|
1348
|
+
function oneAtATime<T>(ask: () => Promise<T>): Promise<T> {
|
|
1349
|
+
const next = asking.then(ask, ask);
|
|
1350
|
+
asking = next.catch(() => undefined);
|
|
1351
|
+
return next;
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
/**
|
|
1355
|
+
* A worker out of time has reported what it did, where it left off and how
|
|
1356
|
+
* much more it needs. The user decides (auto mode: once, and only from what
|
|
1357
|
+
* is left before the QA gate's reserve); a grant past that grows the budget,
|
|
1358
|
+
* and the same agent carries on. Resolves with the ms granted, 0 to stop it.
|
|
1359
|
+
*/
|
|
1360
|
+
function moreTimeFor(task: Task, deps: WorkflowDeps, domain: Domain, instruction: string, id: string): NonNullable<AgentTime["onTimeUp"]> {
|
|
1361
|
+
let asked = 0;
|
|
1362
|
+
return (run, report) => oneAtATime(async () => {
|
|
1363
|
+
const result = parseWorkerResult(domain, report);
|
|
1364
|
+
// A report with nothing left to do finished in time.
|
|
1365
|
+
if (!result.leftOff && !result.moreTime) return 0;
|
|
1366
|
+
asked += 1;
|
|
1367
|
+
const label = AGENT_LABELS[domain];
|
|
1368
|
+
const wanted = result.moreTime?.minutes ?? DEFAULT_MORE_MINUTES;
|
|
1369
|
+
const now = budgetFor(task, deps);
|
|
1370
|
+
if (!now) return 0;
|
|
1371
|
+
const minutes = await decideMoreTime(task, deps, { label, instruction, result, wanted, run, asked, state: now.state });
|
|
1372
|
+
if (minutes <= 0) return 0;
|
|
1373
|
+
const over = Math.max(0, Math.ceil((minutes * 60_000 - now.state.windowMs) / 60_000));
|
|
1374
|
+
updateBudget(deps.root, deps.configDir, task.id, (budget) => {
|
|
1375
|
+
budget.granted += over;
|
|
1376
|
+
const entry = budget.allotments.find((allotment) => allotment.id === id);
|
|
1377
|
+
if (entry) Object.assign(entry, { minutes: entry.minutes + minutes, granted: entry.granted + minutes });
|
|
1378
|
+
});
|
|
1379
|
+
recordDecision(task, `${label} ran out of time; ${isAutoMode(deps.root, deps.configDir, task.id) ? "auto mode gave it" : "the user gave it"} ${minutes} more minutes${over > 0 ? `, ${over} of them added to the task's budget` : ""}. Left off: ${oneLine(result.leftOff ?? result.moreTime?.reason ?? "", 200)}`);
|
|
1380
|
+
return minutes * 60_000;
|
|
1381
|
+
});
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
interface MoreTimeAsk {
|
|
1385
|
+
label: string;
|
|
1386
|
+
instruction: string;
|
|
1387
|
+
result: WorkerResult;
|
|
1388
|
+
wanted: number;
|
|
1389
|
+
run: AgentRun;
|
|
1390
|
+
asked: number;
|
|
1391
|
+
state: BudgetState;
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
const MORE_TIME = "Give it the time it asks for";
|
|
1395
|
+
const OTHER_TIME = "Give a different amount";
|
|
1396
|
+
|
|
1397
|
+
async function decideMoreTime(task: Task, deps: WorkflowDeps, ask: MoreTimeAsk): Promise<number> {
|
|
1398
|
+
const { label, instruction, result, wanted, run, asked, state } = ask;
|
|
1399
|
+
if (isAutoMode(deps.root, deps.configDir, task.id)) {
|
|
1400
|
+
// Nobody to ask: once, and only from time the task still has before the QA gate's reserve.
|
|
1401
|
+
if (asked === 1 && wanted * 60_000 <= state.windowMs) return wanted;
|
|
1402
|
+
recordDecision(task, `Auto mode: ${label} ran out of time and was not given more (${asked > 1 ? "it already had more once" : `it asked for ${wanted} minutes, ${formatMinutes(state.windowMs)} were left`}).`);
|
|
1403
|
+
return 0;
|
|
1404
|
+
}
|
|
1405
|
+
const over = Math.max(0, Math.ceil((wanted * 60_000 - state.windowMs) / 60_000));
|
|
1406
|
+
const had = run.allotMs ?? 0;
|
|
1407
|
+
const choice = await deps.choose(
|
|
1408
|
+
[
|
|
1409
|
+
`${label} is out of time: it had ${formatMinutes(had + (run.extendedMs ?? 0))} for "${oneLine(instruction, 160)}".`,
|
|
1410
|
+
result.completed ? `Done so far: ${oneLine(result.completed, 400)}` : "",
|
|
1411
|
+
result.leftOff ? `Left to do: ${oneLine(result.leftOff, 400)}` : "",
|
|
1412
|
+
`It needs about ${wanted} more minutes${result.moreTime?.reason ? `: ${oneLine(result.moreTime.reason, 200)}` : ""}.`,
|
|
1413
|
+
`The task has used ${formatMinutes(state.usedMs)} of ${formatMinutes(state.totalMs)} (${formatMinutes(state.leftMs)} left)${over > 0 ? `; ${wanted} more minutes adds ${over} to its budget` : ""}.`,
|
|
1414
|
+
`Give ${label} ${wanted} more minutes to finish?`,
|
|
1415
|
+
].filter(Boolean).join("\n\n"),
|
|
1416
|
+
[MORE_TIME, OTHER_TIME, `Stop ${label} here`],
|
|
1417
|
+
);
|
|
1418
|
+
if (choice === MORE_TIME) return wanted;
|
|
1419
|
+
if (choice === OTHER_TIME) return parseMinutes((await deps.ask(`How many more minutes for ${label}?`)) ?? "") ?? 0;
|
|
1420
|
+
recordDecision(task, `The user stopped ${label} at its time limit. Left off: ${oneLine(result.leftOff ?? "", 200)}`);
|
|
1421
|
+
return 0;
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
/** Stopped at its time with work left: a report that finished everything as time ran out is not. */
|
|
1425
|
+
function stoppedForTime(outcome: WorkerOutcome): boolean {
|
|
1426
|
+
return Boolean(outcome.run.timeUp && (outcome.result.leftOff || outcome.result.moreTime));
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
/** How a step's time went, for the oracle: out of time and stopped, or given more. */
|
|
1430
|
+
function timeReport(outcome: WorkerOutcome): string {
|
|
1431
|
+
const { run, result } = outcome;
|
|
1432
|
+
const label = AGENT_LABELS[result.domain];
|
|
1433
|
+
if (stoppedForTime(outcome)) {
|
|
1434
|
+
return [
|
|
1435
|
+
`${label} ran out of its time and was not given more: this step is unfinished.`,
|
|
1436
|
+
result.leftOff ? `Left off: ${oneLine(result.leftOff, 600)}` : "",
|
|
1437
|
+
result.moreTime ? `It asked for ${result.moreTime.minutes ?? "more"} minutes${result.moreTime.reason ? `: ${oneLine(result.moreTime.reason, 300)}` : ""}.` : "",
|
|
1438
|
+
"Decide with the user: trim the scope, ask for task time with action=budget, or wrap up with what is done.",
|
|
1439
|
+
].filter(Boolean).join("\n");
|
|
1440
|
+
}
|
|
1441
|
+
return run.extendedMs ? `${label} ran out of its ${formatMinutes(run.allotMs ?? 0)} and was given ${formatMinutes(run.extendedMs)} more.` : "";
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
/**
|
|
1445
|
+
* `action=budget`: with no minutes, where the budget stands; with minutes and
|
|
1446
|
+
* a reason, the oracle asks the user for more task time. Only the user can
|
|
1447
|
+
* grant it; in auto mode nobody can, and the task wraps up.
|
|
1448
|
+
*/
|
|
1449
|
+
async function handleBudget(task: Task, params: OrchestrateParams, deps: WorkflowDeps): Promise<string> {
|
|
1450
|
+
const now = budgetFor(task, deps);
|
|
1451
|
+
if (!now) return "This task has no time budget. The user sets one with /bot-lobby budget <minutes>.";
|
|
1452
|
+
const minutes = params.minutes;
|
|
1453
|
+
if (!minutes || minutes <= 0) return allotmentReport(now.budget);
|
|
1454
|
+
const reason = params.reason?.trim() || params.text?.trim();
|
|
1455
|
+
if (!reason) throw new Error("budget requires reason: why the task needs more time");
|
|
1456
|
+
if (isAutoMode(deps.root, deps.configDir, task.id)) {
|
|
1457
|
+
recordDecision(task, `Auto mode: asked for ${minutes} more minutes (${oneLine(reason, 160)}), which only the user can give.`);
|
|
1458
|
+
return "Auto mode: nobody can give the task more time. Wrap up with what is done: finish or drop the step in hand, run the QA gate if there is room, and tell the user what is left.";
|
|
1459
|
+
}
|
|
1460
|
+
const choice = await deps.choose(
|
|
1461
|
+
[`The oracle asks for ${minutes} more minutes on ${task.id}: ${oneLine(reason, 400)}`, budgetLine(now.budget, now.state)].join("\n\n"),
|
|
1462
|
+
[`Give ${minutes} more minutes`, OTHER_TIME, "No"],
|
|
1463
|
+
);
|
|
1464
|
+
const granted = choice === `Give ${minutes} more minutes` ? minutes : choice === OTHER_TIME ? parseMinutes((await deps.ask(`How many more minutes for ${task.id}?`)) ?? "") ?? 0 : 0;
|
|
1465
|
+
if (granted <= 0) {
|
|
1466
|
+
recordDecision(task, `The user did not give ${minutes} more minutes: ${oneLine(reason, 200)}`);
|
|
1467
|
+
return "The user did not give more time. Wrap up with what is done and tell them what is left.";
|
|
1468
|
+
}
|
|
1469
|
+
updateBudget(deps.root, deps.configDir, task.id, (budget) => {
|
|
1470
|
+
budget.granted += granted;
|
|
1471
|
+
});
|
|
1472
|
+
recordDecision(task, `The user gave the task ${granted} more minutes: ${oneLine(reason, 200)}`);
|
|
1473
|
+
return `The user gave the task ${granted} more minutes.`;
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
/** What each delegation was given, newest last. */
|
|
1477
|
+
function allotmentReport(budget: TaskBudget): string {
|
|
1478
|
+
const rows = budget.allotments.slice(-8).map((entry) => `- ${entry.who}: ${entry.minutes}m${entry.granted > 0 ? ` (${entry.granted} granted)` : ""} — ${entry.what}${entry.outcome ? ` · ${entry.outcome}` : entry.endedAt ? "" : " · running"}`);
|
|
1479
|
+
return rows.length > 0 ? `Allotted so far:\n${rows.join("\n")}` : "Nothing allotted yet.";
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
/** Every orchestrate result ends with where the budget stands, when the task has one. */
|
|
1483
|
+
function budgetFooter(task: Task, deps: WorkflowDeps): string {
|
|
1484
|
+
const now = budgetFor(task, deps);
|
|
1485
|
+
return now ? `\n\n${budgetLine(now.budget, now.state)}` : "";
|
|
1486
|
+
}
|
|
1487
|
+
|
|
939
1488
|
const HANDLERS: Record<OrchestrateAction, (task: Task, params: OrchestrateParams, deps: WorkflowDeps) => Promise<string> | string> = {
|
|
940
1489
|
clarify: handleClarify,
|
|
941
1490
|
scout: handleScout,
|
|
@@ -951,6 +1500,7 @@ const HANDLERS: Record<OrchestrateAction, (task: Task, params: OrchestrateParams
|
|
|
951
1500
|
block: handleBlock,
|
|
952
1501
|
resume: handleResume,
|
|
953
1502
|
decide: handleDecide,
|
|
1503
|
+
budget: handleBudget,
|
|
954
1504
|
status: (task) => describeTask(task),
|
|
955
1505
|
cancel: handleCancel,
|
|
956
1506
|
};
|
|
@@ -991,11 +1541,11 @@ export async function runWorkflowAction(params: OrchestrateParams, deps: Workflo
|
|
|
991
1541
|
const message = await handler(task, params, tracked);
|
|
992
1542
|
const runs = recordRunLog(task, finished, deps);
|
|
993
1543
|
saveTask(deps.root, deps.configDir, task);
|
|
994
|
-
return { ok: true, taskId: task.id, state: task.state, message: `${message}${runsFooter(runs)}`, runs };
|
|
1544
|
+
return { ok: true, taskId: task.id, state: task.state, message: `${message}${runsFooter(runs)}${budgetFooter(task, deps)}`, runs };
|
|
995
1545
|
} catch (error) {
|
|
996
1546
|
const runs = recordRunLog(task, finished, deps);
|
|
997
1547
|
saveTask(deps.root, deps.configDir, task);
|
|
998
|
-
return { ok: false, taskId: task.id, state: task.state, message: `Rejected: ${(error as Error).message}`, runs };
|
|
1548
|
+
return { ok: false, taskId: task.id, state: task.state, message: `Rejected: ${(error as Error).message}${budgetFooter(task, deps)}`, runs };
|
|
999
1549
|
}
|
|
1000
1550
|
}
|
|
1001
1551
|
|