@a-t-h-i/bot-lobby 0.6.53 → 0.6.55
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 +1 -1
- package/package.json +1 -1
- package/prompts/expert.md +17 -0
- package/prompts/goal-check.md +44 -0
- package/prompts/master.md +70 -32
- package/prompts/worker.md +25 -11
- package/src/desk/board-client.ts +65 -0
- package/src/desk/board.ts +200 -0
- package/src/desk/client-extension.ts +31 -1
- package/src/desk/ipc.ts +16 -4
- package/src/desk/session.ts +129 -1
- package/src/execution/agent-runner.ts +27 -132
- package/src/execution/delegation.ts +112 -38
- package/src/execution/live-run.ts +3 -14
- package/src/execution/pi-runner.ts +30 -163
- package/src/execution/worker-trees.ts +396 -0
- package/src/index.ts +6 -0
- package/src/lobby/feed.ts +1 -1
- package/src/lobby/host.ts +2 -0
- package/src/lobby/planner.ts +2 -2
- package/src/lobby/pr-review.ts +3 -3
- package/src/lobby/quickfix.ts +4 -3
- package/src/lobby/recovery.ts +2 -10
- package/src/lobby/runtime.ts +1 -1
- package/src/lobby/service.ts +3 -1
- package/src/master/goal-check.ts +114 -0
- package/src/master/master.ts +60 -24
- package/src/master/research.ts +3 -6
- package/src/pi/commands.ts +5 -45
- package/src/pi/delegation.ts +23 -4
- package/src/pi/events.ts +6 -19
- package/src/pi/grants.ts +89 -0
- package/src/pi/model-settings.ts +2 -4
- package/src/pi/model-support.ts +12 -5
- package/src/pi/route.ts +1 -1
- package/src/pi/run-summary.ts +3 -7
- package/src/pi/start-flags.ts +3 -3
- package/src/pi/start-task.ts +2 -8
- package/src/pi/tools.ts +10 -12
- package/src/roles/worker.ts +0 -18
- package/src/schemas/configuration.ts +81 -39
- package/src/schemas/findings.ts +0 -12
- package/src/schemas/task.ts +12 -1
- package/src/state/metrics.ts +1 -1
- package/src/state/work-clock.ts +140 -0
- package/src/state/work-time.ts +4 -7
- package/src/webui/api/index.ts +2 -1
- package/src/webui/api/settings.ts +6 -18
- package/src/webui/dev/fake-service.ts +14 -0
- package/src/webui/protocol.ts +23 -0
- package/src/workflow/workflow.ts +204 -344
- package/webui/dist/assets/{TerminalView-CbRc77C8.js → TerminalView-CIfjkb5H.js} +1 -1
- package/webui/dist/assets/index-BkXhtB6A.css +2 -0
- package/webui/dist/assets/index-DIM2-FUQ.js +114 -0
- package/webui/dist/build.json +10 -6
- package/webui/dist/index.html +2 -2
- package/src/state/budget.ts +0 -301
- package/src/state/step-limits.ts +0 -41
- package/webui/dist/assets/index-Cca5uEVs.js +0 -115
- package/webui/dist/assets/index-DsVdXmh5.css +0 -2
package/src/workflow/workflow.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
|
|
2
2
|
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
|
-
import {
|
|
4
|
-
import type { AgentRun, Pushback, ResearchResult, ReviewResult
|
|
3
|
+
import type { BotLobbyConfig, ProfileResolver } from "../schemas/configuration.ts";
|
|
4
|
+
import type { AgentRun, Pushback, ResearchResult, ReviewResult } from "../schemas/findings.ts";
|
|
5
5
|
import {
|
|
6
6
|
MAX_RUN_LOG,
|
|
7
7
|
MAX_WORKER_RECORDS,
|
|
@@ -30,12 +30,11 @@ import type { AskQuestion, AskResult } from "../ask/types.ts";
|
|
|
30
30
|
import { mapConcurrent } from "../execution/agent-runner.ts";
|
|
31
31
|
import { taskExecution } from "../execution/task-cancellation.ts";
|
|
32
32
|
import { parseWorkerResult } from "../roles/worker.ts";
|
|
33
|
-
import { autoNote,
|
|
33
|
+
import { autoNote, DeskSession } from "../desk/session.ts";
|
|
34
|
+
import { closeWorkerTrees, mapToTop, mergeWorkerTrees, openWorkerTrees, type MergeOutcome } from "../execution/worker-trees.ts";
|
|
34
35
|
import type { Handover } from "../desk/desk.ts";
|
|
35
36
|
import { changedFiles, commitBefore, commitFiles, commitMessage, headCommit, readRepositoryDiff, uncommitted } from "../execution/git.ts";
|
|
36
37
|
import { appendChange, explainChanges, provenanceLines, provenanceSummary, readChanges, type FileProvenance } from "../state/changes.ts";
|
|
37
|
-
import { budgetLine, budgetState, formatMinutes, MIN_READ_MS, parseMinutes, qaAllotment, readBudget, readerAllotment, updateBudget, workerAllotment, type Allotment, type BudgetState, type TaskBudget } from "../state/budget.ts";
|
|
38
|
-
import type { AgentTime } from "../execution/agent-runner.ts";
|
|
39
38
|
import {
|
|
40
39
|
loadScoutResults,
|
|
41
40
|
runReviewer,
|
|
@@ -49,6 +48,9 @@ import {
|
|
|
49
48
|
import { researchResultPath, runResearch, type ResearchOutcome, type ResearchRequest } from "../master/research.ts";
|
|
50
49
|
import { assessReconnaissance, completionBlockers, decideReviewLoop, recordDecision } from "../master/decisions.ts";
|
|
51
50
|
import { detectSharedFiles, summarizeOutcomes } from "../master/synthesis.ts";
|
|
51
|
+
import { boardContext, boardPath, filterPosts, formatPosts, readBoard } from "../desk/board.ts";
|
|
52
|
+
import { runGoalCheck } from "../master/goal-check.ts";
|
|
53
|
+
import { createHash } from "node:crypto";
|
|
52
54
|
import { tail, truncate } from "../text.ts";
|
|
53
55
|
import { isAutoMode } from "../state/auto.ts";
|
|
54
56
|
import { briefRejection } from "./brief.ts";
|
|
@@ -56,8 +58,7 @@ import { assertNoPendingApprovals, pendingApprovals, requestApproval, resolveApp
|
|
|
56
58
|
import { withBlockingRequest } from "../state/blocking-requests.ts";
|
|
57
59
|
import { pingApproval } from "../pi/notify.ts";
|
|
58
60
|
import { describeRun, runLogEntry } from "../pi/run-summary.ts";
|
|
59
|
-
import {
|
|
60
|
-
import { STEP_LIMIT_MS, grantStepTime, reserveStepTime, stepTimeLeft } from "../state/step-limits.ts";
|
|
61
|
+
import { skippedStepsNote } from "../pi/plan-checklist.ts";
|
|
61
62
|
import { noteWorkerRun } from "../state/work-time.ts";
|
|
62
63
|
import { nextStates } from "./transitions.ts";
|
|
63
64
|
import type { FileHinter } from "../classifier/files.ts";
|
|
@@ -67,7 +68,7 @@ import type { EffortRouter } from "../classifier/effort.ts";
|
|
|
67
68
|
import { answerClarify } from "../classifier/triage.ts";
|
|
68
69
|
import { appendMetrics, metricFromRun } from "../state/metrics.ts";
|
|
69
70
|
import { markCommentsAddressed, pendingComments, readPlanComments } from "../state/comments.ts";
|
|
70
|
-
import { builtSomething, fastNext, onFastTrack, parseRoster, PLANNED_NEXT, qaRequired,
|
|
71
|
+
import { builtSomething, fastNext, onFastTrack, parseRoster, PLANNED_NEXT, qaRequired, qaTookPart, readRequest, rosterWords, satOut, trackSummary } from "./track.ts";
|
|
71
72
|
import { requestBesidePlan } from "../state/backlog.ts";
|
|
72
73
|
import { missingWorktree, taskCwd } from "../execution/workspace.ts";
|
|
73
74
|
import { completionReview, deferReview, refreshReview } from "../delivery/review.ts";
|
|
@@ -96,7 +97,6 @@ export const ORCHESTRATE_ACTIONS = [
|
|
|
96
97
|
"block",
|
|
97
98
|
"resume",
|
|
98
99
|
"decide",
|
|
99
|
-
"budget",
|
|
100
100
|
"track",
|
|
101
101
|
"status",
|
|
102
102
|
"cancel",
|
|
@@ -122,9 +122,7 @@ export interface OrchestrateParams {
|
|
|
122
122
|
/** implement: the concrete instruction for the worker. */
|
|
123
123
|
task?: string;
|
|
124
124
|
/** implement: several domains at once, run in parallel through the file desk. */
|
|
125
|
-
assignments?: Array<{ domain: string; task: string
|
|
126
|
-
/** implement: minutes the step may take under the task's time budget; budget: extra minutes to ask the user for. */
|
|
127
|
-
minutes?: number;
|
|
125
|
+
assignments?: Array<{ domain: string; task: string }>;
|
|
128
126
|
/** knowledge: which persistent file the text belongs to. */
|
|
129
127
|
kind?: KnowledgeKind;
|
|
130
128
|
/** compact: the knowledge file being rewritten; script: a drafted script to take instead of `text`. */
|
|
@@ -157,6 +155,8 @@ export interface WorkflowDeps {
|
|
|
157
155
|
config: BotLobbyConfig;
|
|
158
156
|
/** Per-run model/thinking/time limit, clamped to each model; plain settings when absent. */
|
|
159
157
|
profile?: ProfileResolver;
|
|
158
|
+
/** The model and thinking ask_expert consults on, resolved like the profiles; the settings' own when absent. */
|
|
159
|
+
expert?: { model?: string; thinking: string };
|
|
160
160
|
signal?: AbortSignal;
|
|
161
161
|
onUpdate?: (run: AgentRun) => void;
|
|
162
162
|
ask: (question: string) => Promise<string | undefined>;
|
|
@@ -361,13 +361,11 @@ async function handleScout(task: Task, params: OrchestrateParams, deps: Workflow
|
|
|
361
361
|
if (task.state === "clarifying") transition(task, "scouting");
|
|
362
362
|
const verifying = task.state === "synthesizing";
|
|
363
363
|
const instruction = params.instruction?.trim() || "Investigate this request and report findings the Master needs.";
|
|
364
|
-
const time = readerTime(task, deps, "SCOUTS", instruction, SCOUT_SHARE, deps.config.scout.timeoutMs, "scouting");
|
|
365
364
|
const outcomes = await runScouts(
|
|
366
365
|
{
|
|
367
366
|
taskId: task.id,
|
|
368
367
|
taskText: taskRequest(task),
|
|
369
368
|
instruction,
|
|
370
|
-
...(time ? { time } : {}),
|
|
371
369
|
domains,
|
|
372
370
|
cwd: deps.cwd,
|
|
373
371
|
dataRoots: readDataRoots(deps.root, deps.configDir),
|
|
@@ -382,7 +380,6 @@ async function handleScout(task: Task, params: OrchestrateParams, deps: Workflow
|
|
|
382
380
|
},
|
|
383
381
|
deps.runProcess ?? spawnPiProcess,
|
|
384
382
|
);
|
|
385
|
-
if (time) settleAllotment(task, deps, time.id, "finished");
|
|
386
383
|
if (!verifying) transition(task, "synthesizing");
|
|
387
384
|
const involved = outcomes.filter((outcome) => outcome.usable).map((outcome) => outcome.result.domain);
|
|
388
385
|
task.domains = [...new Set([...task.domains, ...involved])];
|
|
@@ -466,13 +463,11 @@ function researchRequestFor(
|
|
|
466
463
|
domain: Domain,
|
|
467
464
|
instruction: string,
|
|
468
465
|
taskDir: string,
|
|
469
|
-
time?: AgentTime,
|
|
470
466
|
): ResearchRequest {
|
|
471
467
|
return {
|
|
472
468
|
taskId: task.id,
|
|
473
469
|
domain,
|
|
474
470
|
instruction,
|
|
475
|
-
...(time ? { time } : {}),
|
|
476
471
|
config: deps.config,
|
|
477
472
|
profile: deps.profile,
|
|
478
473
|
cwd: deps.cwd,
|
|
@@ -490,9 +485,7 @@ async function handleResearch(task: Task, params: OrchestrateParams, deps: Workf
|
|
|
490
485
|
const instruction = params.instruction?.trim();
|
|
491
486
|
if (!instruction) throw new Error("research requires instruction (the question to investigate)");
|
|
492
487
|
const taskDir = taskDirFor(deps.root, deps.configDir, task.id);
|
|
493
|
-
const
|
|
494
|
-
const outcome = await runResearch(researchRequestFor(deps, task, domain, instruction, taskDir, time), deps.runProcess ?? spawnPiProcess);
|
|
495
|
-
if (time) settleAllotment(task, deps, time.id, "finished");
|
|
488
|
+
const outcome = await runResearch(researchRequestFor(deps, task, domain, instruction, taskDir), deps.runProcess ?? spawnPiProcess);
|
|
496
489
|
appendResearchLog(taskDir, domain, outcome);
|
|
497
490
|
recordAdvisoryPushbacks(task, [{ pushback: outcome.result.pushback, who: domain }]);
|
|
498
491
|
return researchReport(outcome, researchResultPath(taskDir, domain));
|
|
@@ -624,11 +617,12 @@ function recordAdvisoryPushbacks(task: Task, entries: Array<{ pushback?: Pushbac
|
|
|
624
617
|
|
|
625
618
|
const FULL_NEXT = "Next: inspect the diff, then run action=qa once this domain's work is complete.";
|
|
626
619
|
|
|
627
|
-
function workerReport(outcome: WorkerOutcome, approvals: Approval[], pushback?: Approval, next = FULL_NEXT): string {
|
|
620
|
+
function workerReport(outcome: WorkerOutcome, approvals: Approval[], pushback?: Approval, next = FULL_NEXT, fullReport = ""): string {
|
|
628
621
|
const { result, run, issues } = outcome;
|
|
629
622
|
const objection = result.pushback;
|
|
630
623
|
return [
|
|
631
624
|
`Worker ${result.domain}: ${run.status}${run.error ? ` (${run.error})` : ""}`,
|
|
625
|
+
fullReport ? `Full report: ${fullReport} (read it for detail the lines below shorten)` : "",
|
|
632
626
|
result.completed ? `Completed: ${truncate(result.completed, 1200)}` : "",
|
|
633
627
|
result.filesChanged.length > 0
|
|
634
628
|
? `Files changed:\n${result.filesChanged.map((file) => `- ${file.path} — ${file.change}`).join("\n")}`
|
|
@@ -706,10 +700,22 @@ function workerTaskText(task: Task, planBudget = 6000): string {
|
|
|
706
700
|
.join("\n\n");
|
|
707
701
|
}
|
|
708
702
|
|
|
709
|
-
|
|
703
|
+
/** The task's board: one file in its directory that every agent of the task posts to and reads. */
|
|
704
|
+
function taskBoard(deps: WorkflowDeps, task: Task): string {
|
|
705
|
+
return boardPath(taskDirFor(deps.root, deps.configDir, task.id));
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/** What the agents posted to the board during this action, for the oracle. */
|
|
709
|
+
function boardNews(deps: WorkflowDeps, task: Task, after: number): string {
|
|
710
|
+
const posts = filterPosts(readBoard(taskBoard(deps, task)), { after, limit: 40 });
|
|
711
|
+
return posts.length > 0 ? `Task board, new posts:\n${formatPosts(posts)}` : "";
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function workerRequest(deps: WorkflowDeps, task: Task, domain: Domain, instruction: string, alongside?: readonly Domain[]): WorkerRequest {
|
|
710
715
|
const ask = askRelay(task, deps, domain);
|
|
711
716
|
return {
|
|
712
|
-
|
|
717
|
+
board: taskBoard(deps, task),
|
|
718
|
+
...(alongside && alongside.length > 1 ? { alongside } : {}),
|
|
713
719
|
...(ask ? { ask } : {}),
|
|
714
720
|
// QA writing tests works to the same budget as the gate: the risk of what is built so far.
|
|
715
721
|
...(domain === "qa" && task.qaRisk ? { risk: qaRiskBrief(task.qaRisk) } : {}),
|
|
@@ -722,6 +728,7 @@ function workerRequest(deps: WorkflowDeps, task: Task, domain: Domain, instructi
|
|
|
722
728
|
dataRoots: readDataRoots(deps.root, deps.configDir),
|
|
723
729
|
config: deps.config,
|
|
724
730
|
profile: deps.profile,
|
|
731
|
+
...(deps.expert ? { expert: deps.expert } : {}),
|
|
725
732
|
signal: deps.signal,
|
|
726
733
|
onUpdate: deps.onUpdate,
|
|
727
734
|
...(deps.hints ? { hints: deps.hints } : {}),
|
|
@@ -799,8 +806,6 @@ function recordWorkerRun(task: Task, run: AgentRun): void {
|
|
|
799
806
|
interface Assignment {
|
|
800
807
|
domain: Domain;
|
|
801
808
|
instruction: string;
|
|
802
|
-
/** Minutes the oracle gave the step, under a time budget. */
|
|
803
|
-
minutes?: number;
|
|
804
809
|
}
|
|
805
810
|
|
|
806
811
|
/** The delegation as a list: `assignments` for a parallel batch, otherwise the single domain/task. */
|
|
@@ -809,7 +814,7 @@ function parseAssignments(params: OrchestrateParams): Assignment[] {
|
|
|
809
814
|
const list = params.assignments.map((entry) => {
|
|
810
815
|
const instruction = entry.task?.trim();
|
|
811
816
|
if (!instruction) throw new Error("every assignment needs a task (what to implement)");
|
|
812
|
-
return { domain: parseDomain(entry.domain, "implement"), instruction
|
|
817
|
+
return { domain: parseDomain(entry.domain, "implement"), instruction };
|
|
813
818
|
});
|
|
814
819
|
const domains = list.map((entry) => entry.domain);
|
|
815
820
|
if (new Set(domains).size !== domains.length) throw new Error("parallel assignments need distinct domains (one worker per domain)");
|
|
@@ -818,7 +823,7 @@ function parseAssignments(params: OrchestrateParams): Assignment[] {
|
|
|
818
823
|
const domain = parseDomain(params.domain, "implement");
|
|
819
824
|
const instruction = params.task?.trim();
|
|
820
825
|
if (!instruction) throw new Error("implement requires task (what to implement)");
|
|
821
|
-
return [{ domain, instruction
|
|
826
|
+
return [{ domain, instruction }];
|
|
822
827
|
}
|
|
823
828
|
|
|
824
829
|
/**
|
|
@@ -918,10 +923,9 @@ function provenanceNote(files: readonly FileProvenance[] | undefined): string {
|
|
|
918
923
|
}
|
|
919
924
|
|
|
920
925
|
/** Record one worker's outcome on the task and return its report for the Master. */
|
|
921
|
-
function absorbWorkerOutcome(task: Task, deps: WorkflowDeps, outcome: WorkerOutcome
|
|
926
|
+
function absorbWorkerOutcome(task: Task, deps: WorkflowDeps, outcome: WorkerOutcome): string {
|
|
922
927
|
const domain = outcome.result.domain;
|
|
923
928
|
recordWorkerRun(task, outcome.run);
|
|
924
|
-
if (time) settleAllotment(task, deps, time.id, stoppedForTime(outcome) ? "out of time" : outcome.run.status === "success" ? "finished" : "stopped");
|
|
925
929
|
appendChange(deps.root, deps.configDir, {
|
|
926
930
|
source: "worker",
|
|
927
931
|
id: outcome.run.runId,
|
|
@@ -938,8 +942,30 @@ function absorbWorkerOutcome(task: Task, deps: WorkflowDeps, outcome: WorkerOutc
|
|
|
938
942
|
task.blockers = [...task.blockers.filter((blocker) => blocker.domain !== domain), ...outcome.result.blockers];
|
|
939
943
|
updateScratchpad(deps, task, outcome);
|
|
940
944
|
const grown = qaJoinsGrownFastTask(task, outcome);
|
|
941
|
-
const
|
|
942
|
-
|
|
945
|
+
const full = saveFullReport(deps, task, outcome);
|
|
946
|
+
const report = workerReport(outcome, approvals, pushback, onFastTrack(task) ? fastNext(task) : qaRequired(task) ? FULL_NEXT : PLANNED_NEXT, full);
|
|
947
|
+
return [report, grown].filter(Boolean).join("\n");
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/** Reports longer than this are kept whole in a file and the oracle gets the path. */
|
|
951
|
+
const LONG_REPORT = 2400;
|
|
952
|
+
|
|
953
|
+
/**
|
|
954
|
+
* Keep a long worker report whole in the task's `reports/` folder, so the
|
|
955
|
+
* oracle reads what it needs from the file instead of getting a shortened
|
|
956
|
+
* copy relayed through its own context. Returns the path, or "".
|
|
957
|
+
*/
|
|
958
|
+
function saveFullReport(deps: WorkflowDeps, task: Task, outcome: WorkerOutcome): string {
|
|
959
|
+
const output = outcome.run.output?.trim() ?? "";
|
|
960
|
+
if (output.length <= LONG_REPORT) return "";
|
|
961
|
+
const stamp = (outcome.run.finishedAt ?? new Date().toISOString()).replace(/[:.]/g, "-");
|
|
962
|
+
const path = join(taskDirFor(deps.root, deps.configDir, task.id), "reports", `${outcome.result.domain}-${stamp}.md`);
|
|
963
|
+
try {
|
|
964
|
+
writeFileEnsured(path, `${output}\n`);
|
|
965
|
+
return path;
|
|
966
|
+
} catch {
|
|
967
|
+
return "";
|
|
968
|
+
}
|
|
943
969
|
}
|
|
944
970
|
|
|
945
971
|
/**
|
|
@@ -1005,7 +1031,6 @@ async function handleImplement(task: Task, params: OrchestrateParams, deps: Work
|
|
|
1005
1031
|
if (short.length > 0) throw new Error(short.join("\n"));
|
|
1006
1032
|
for (const { domain } of assignments) assertNoPendingApprovals(task, domain);
|
|
1007
1033
|
for (const { domain } of assignments) if (!task.domains.includes(domain)) task.domains.push(domain);
|
|
1008
|
-
// Under a time budget every step is given its share before any starts; a spent budget starts none.
|
|
1009
1034
|
if (task.track && onFastTrack(task)) task.track.roster = parseRoster([...task.track.roster, ...assignments.map((entry) => entry.domain)]);
|
|
1010
1035
|
if (starting) startFast(task);
|
|
1011
1036
|
if (task.track?.autoPlan) addFastSteps(task, deps, assignments);
|
|
@@ -1013,24 +1038,16 @@ async function handleImplement(task: Task, params: OrchestrateParams, deps: Work
|
|
|
1013
1038
|
await takeBaseline(task, deps);
|
|
1014
1039
|
// A QA step after work is in verifies it at its risk: read the change first.
|
|
1015
1040
|
if (assignments.some((entry) => entry.domain === "qa") && builtSomething(task)) await assessBuilt(task, deps);
|
|
1016
|
-
const
|
|
1041
|
+
const boardBefore = readBoard(taskBoard(deps, task)).at(-1)?.seq ?? 0;
|
|
1017
1042
|
let report: string;
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
} else report = await runParallelWorkers(task, deps, assignments, times);
|
|
1024
|
-
} finally {
|
|
1025
|
-
// A step that never reported (the call failed) does not stay "running" in the budget.
|
|
1026
|
-
for (const time of times.values()) {
|
|
1027
|
-
settleAllotment(task, deps, time.id, "stopped");
|
|
1028
|
-
time.finish?.();
|
|
1029
|
-
}
|
|
1030
|
-
}
|
|
1043
|
+
if (assignments.length === 1) {
|
|
1044
|
+
const { domain, instruction } = assignments[0]!;
|
|
1045
|
+
const outcome = await runWorker(workerRequest(deps, task, domain, instruction), deps.runProcess ?? spawnPiProcess);
|
|
1046
|
+
report = absorbWorkerOutcome(task, deps, outcome);
|
|
1047
|
+
} else report = await runParallelWorkers(task, deps, assignments);
|
|
1031
1048
|
const tree = await changeState(deps, task, task.baseline?.head);
|
|
1032
1049
|
const lint = lintNote(await lintTask(task, deps, tree), deps.config.lint.mode, ownersOf(tree?.files));
|
|
1033
|
-
return [report, provenanceNote(tree?.files), lint].filter(Boolean).join("\n\n");
|
|
1050
|
+
return [report, boardNews(deps, task, boardBefore), provenanceNote(tree?.files), lint].filter(Boolean).join("\n\n");
|
|
1034
1051
|
}
|
|
1035
1052
|
|
|
1036
1053
|
/**
|
|
@@ -1130,34 +1147,62 @@ function stepLine(instruction: string): string {
|
|
|
1130
1147
|
* Several domains at once. The workers share one file desk: each claims a file
|
|
1131
1148
|
* before editing it, queues for a busy one, and hands it over with a note; a
|
|
1132
1149
|
* worker that finishes hands over whatever it still holds automatically.
|
|
1150
|
+
* In `worktrees` mode each worker has a checkout of its own instead (no
|
|
1151
|
+
* claims, no queues), and their changes are applied back when the batch ends.
|
|
1152
|
+
* Either way the desk carries the board and the workers' questions to each other.
|
|
1133
1153
|
*/
|
|
1134
|
-
async function runParallelWorkers(task: Task, deps: WorkflowDeps, assignments: Assignment[]
|
|
1135
|
-
const
|
|
1154
|
+
async function runParallelWorkers(task: Task, deps: WorkflowDeps, assignments: Assignment[]): Promise<string> {
|
|
1155
|
+
const batch = assignments.map((entry) => entry.domain);
|
|
1156
|
+
const opened = deps.config.workflow.parallelMode === "worktrees" ? await openWorkerTrees(deps.cwd, batch) : undefined;
|
|
1157
|
+
const trees = opened && typeof opened !== "string" ? opened : undefined;
|
|
1158
|
+
const session = new DeskSession({ cwd: deps.cwd, boardPath: taskBoard(deps, task), peers: true, ...(trees ? { claims: false } : {}) });
|
|
1136
1159
|
await session.open();
|
|
1137
1160
|
let outcomes: WorkerOutcome[];
|
|
1138
1161
|
let unenforced: Domain[];
|
|
1162
|
+
let merged = "";
|
|
1139
1163
|
try {
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1164
|
+
try {
|
|
1165
|
+
outcomes = await mapConcurrent(assignments, deps.config.workflow.maxParallelWorkers, ({ domain, instruction }) => {
|
|
1166
|
+
const request = workerRequest(deps, task, domain, instruction, batch);
|
|
1167
|
+
const tree = trees?.trees.get(domain);
|
|
1168
|
+
if (tree) {
|
|
1169
|
+
request.cwd = tree.cwd;
|
|
1170
|
+
request.ownCheckout = true;
|
|
1171
|
+
}
|
|
1172
|
+
request.agent = {
|
|
1173
|
+
env: session.env(domain),
|
|
1174
|
+
extraTools: session.tools(),
|
|
1175
|
+
onStart: (handle) => session.attach(domain, handle),
|
|
1176
|
+
onAttemptEnd: (run) => {
|
|
1177
|
+
const result = parseWorkerResult(domain, run.output);
|
|
1178
|
+
session.release(domain, (path, next) => autoNote(domain, path, next, result.filesChanged, result.completed));
|
|
1179
|
+
},
|
|
1180
|
+
};
|
|
1181
|
+
return runWorker(request, deps.runProcess ?? spawnPiProcess);
|
|
1182
|
+
});
|
|
1183
|
+
unenforced = trees ? [] : assignments.map((entry) => entry.domain).filter((domain) => !session.greetedBy(domain));
|
|
1184
|
+
} finally {
|
|
1185
|
+
await session.close();
|
|
1186
|
+
}
|
|
1187
|
+
if (trees) {
|
|
1188
|
+
const results = await mergeWorkerTrees(trees, batch, join(taskDirFor(deps.root, deps.configDir, task.id), "patches")).catch((error: unknown) => {
|
|
1189
|
+
// Keep every worktree: nothing is lost while the oracle sorts it out.
|
|
1190
|
+
for (const tree of trees.trees.values()) tree.keep = true;
|
|
1191
|
+
return batch.map((worker): MergeOutcome => ({ worker, status: "conflict", patch: "", saved: trees.trees.get(worker)?.dir ?? "", reason: `merging back failed (${(error as Error).message}); the worktree is kept as it was` }));
|
|
1192
|
+
});
|
|
1193
|
+
// The edits were made in the worktrees: the task's record names the same files in its own tree.
|
|
1194
|
+
outcomes = outcomes.map((outcome) => (outcome.run.edited?.length ? { ...outcome, run: { ...outcome.run, edited: outcome.run.edited.map((path) => mapToTop(trees, outcome.result.domain, path)) } } : outcome));
|
|
1195
|
+
merged = mergeReport(results);
|
|
1196
|
+
}
|
|
1154
1197
|
} finally {
|
|
1155
|
-
await
|
|
1198
|
+
if (trees) await closeWorkerTrees(trees);
|
|
1156
1199
|
}
|
|
1157
|
-
const reports = outcomes.map((outcome) => absorbWorkerOutcome(task, deps, outcome
|
|
1200
|
+
const reports = outcomes.map((outcome) => absorbWorkerOutcome(task, deps, outcome));
|
|
1158
1201
|
return [
|
|
1159
|
-
`Parallel batch: ${
|
|
1202
|
+
`Parallel batch: ${batch.join(", ")}${trees ? " (a worktree each)" : ""}.`,
|
|
1203
|
+
typeof opened === "string" ? `Worktrees were not used (${opened}); the workers shared the file desk.` : "",
|
|
1160
1204
|
...reports,
|
|
1205
|
+
merged,
|
|
1161
1206
|
handoverLog(session.handovers()),
|
|
1162
1207
|
unenforced.length > 0
|
|
1163
1208
|
? `File checkout was not enforced for ${unenforced.join(", ")} (bot-lobby did not load in those workers); inspect the diff for overlapping edits.`
|
|
@@ -1167,6 +1212,24 @@ async function runParallelWorkers(task: Task, deps: WorkflowDeps, assignments: A
|
|
|
1167
1212
|
.join("\n\n");
|
|
1168
1213
|
}
|
|
1169
1214
|
|
|
1215
|
+
/** What came of applying each worker's worktree back to the task's tree. */
|
|
1216
|
+
function mergeReport(results: readonly MergeOutcome[]): string {
|
|
1217
|
+
const label = (worker: string): string => AGENT_LABELS[worker as Domain] ?? worker;
|
|
1218
|
+
const applied = results.filter((entry) => entry.status === "applied").map((entry) => label(entry.worker));
|
|
1219
|
+
const kept = results.filter((entry): entry is Extract<MergeOutcome, { status: "conflict" | "partial" }> => entry.status === "conflict" || entry.status === "partial");
|
|
1220
|
+
return [
|
|
1221
|
+
applied.length > 0 ? `Worktrees merged back: ${applied.join(", ")}.` : "",
|
|
1222
|
+
...kept.map((entry) => {
|
|
1223
|
+
const where = [entry.patch ? `patch ${entry.patch}` : "", entry.saved.startsWith("refs/") ? `git ref ${entry.saved}` : entry.saved ? `worktree ${entry.saved}` : ""].filter(Boolean).join(", ");
|
|
1224
|
+
const state =
|
|
1225
|
+
entry.status === "partial"
|
|
1226
|
+
? `, and applying it failed part way: these files are left changed and need checking: ${(entry.leftChanged ?? []).slice(0, 10).join(", ")}`
|
|
1227
|
+
: ", so none of it is in the tree";
|
|
1228
|
+
return `${label(entry.worker)}'s change did not apply (${truncate(entry.reason, 300)})${state}.${where ? ` It is saved (${where}): delegate a fix step to ${label(entry.worker)} to bring it onto the current files (\`git apply --3way\` on the patch, or \`git diff <ref>^ <ref>\`), reconciling it with the others' changes.` : ""}`;
|
|
1229
|
+
}),
|
|
1230
|
+
].filter(Boolean).join("\n");
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1170
1233
|
function handoverLog(handovers: readonly Handover[]): string {
|
|
1171
1234
|
if (handovers.length === 0) return "";
|
|
1172
1235
|
const lines = handovers.map((entry) => `- ${entry.path}: ${entry.from} → ${entry.to}${entry.auto ? " (on finish)" : ""} — ${truncate(entry.note, 200)}`);
|
|
@@ -1254,11 +1317,14 @@ function qaRequest(deps: WorkflowDeps, task: Task, diff: string, risk: QaRiskAss
|
|
|
1254
1317
|
taskId: task.id,
|
|
1255
1318
|
domain: "qa",
|
|
1256
1319
|
taskText: workerTaskText(task, 9000),
|
|
1257
|
-
|
|
1320
|
+
// A clean review sees what was built, not how the builders describe it.
|
|
1321
|
+
workerSummary: deps.config.workflow.cleanReview ? "" : allScratchpads(deps, task),
|
|
1322
|
+
...(deps.config.workflow.cleanReview ? { clean: true } : {}),
|
|
1258
1323
|
...(rounds ? { previousRound: rounds } : {}),
|
|
1259
|
-
scoutOutcomes: loadScoutResults(taskReadDirs(deps.root, deps.configDir, task.id), [...new Set<Domain>(["qa", ...task.domains])]),
|
|
1324
|
+
scoutOutcomes: deps.config.workflow.cleanReview ? [] : loadScoutResults(taskReadDirs(deps.root, deps.configDir, task.id), [...new Set<Domain>(["qa", ...task.domains])]),
|
|
1260
1325
|
diff,
|
|
1261
1326
|
risk: qaRiskBrief(risk),
|
|
1327
|
+
board: boardContext(taskBoard(deps, task), "qa"),
|
|
1262
1328
|
...(provenance && provenance.length > 0 ? { provenance: provenanceLines(provenance) } : {}),
|
|
1263
1329
|
...(lint ? { lint: lintContext(lint, deps.config.lint.mode) } : {}),
|
|
1264
1330
|
instruction: instruction?.trim() || qaInstruction(risk),
|
|
@@ -1371,19 +1437,19 @@ async function handleQa(task: Task, params: OrchestrateParams, deps: WorkflowDep
|
|
|
1371
1437
|
]);
|
|
1372
1438
|
const provenance = tree?.files;
|
|
1373
1439
|
// The gate's base is the task's own; a legacy task measured from an older commit lints from a fresh look.
|
|
1374
|
-
|
|
1440
|
+
// Before any QA agent runs, Jev reads what was built: the gate's depth and test budget follow its risk. Neither waits for the other.
|
|
1441
|
+
const [lint, risk] = await Promise.all([
|
|
1442
|
+
lintTask(task, deps, base === task.baseline?.head ? tree : undefined),
|
|
1443
|
+
assessTask(task, deps, diff, tree, base),
|
|
1444
|
+
]);
|
|
1375
1445
|
const lintLine = lintBrief(lint, deps.config.lint.mode, Boolean(task.lintWaiver));
|
|
1376
|
-
// Before any QA agent runs, Jev reads what was built: the gate's depth and test budget follow its risk.
|
|
1377
|
-
const risk = await assessTask(task, deps, diff, tree, base);
|
|
1378
1446
|
if (!risk.qaRequired) {
|
|
1379
1447
|
const result = checksVerdict(lint, deps, Boolean(task.lintWaiver));
|
|
1380
1448
|
const decision = await settleQaRound(task, result, iterations, deps, true);
|
|
1381
1449
|
const header = `QA gate: ${result.verdict.toUpperCase()} on the engine's checks; no QA agent ran. ${qaRiskLine(risk)}.\nWhy: ${risk.reasoning}\nChecks:\n${result.verification}`;
|
|
1382
1450
|
return qaReport(header, result, decision, provenance, lintLine);
|
|
1383
1451
|
}
|
|
1384
|
-
const
|
|
1385
|
-
const outcome = await runReviewer({ ...qaRequest(deps, task, diff, risk, params.task, provenance, lint), effort: deps.effort, ...(time ? { time } : {}) }, deps.runProcess ?? spawnPiProcess);
|
|
1386
|
-
if (time) settleAllotment(task, deps, time.id, "finished");
|
|
1452
|
+
const outcome = await runReviewer({ ...qaRequest(deps, task, diff, risk, params.task, provenance, lint), effort: deps.effort }, deps.runProcess ?? spawnPiProcess);
|
|
1387
1453
|
const { result, run } = outcome;
|
|
1388
1454
|
recordAdvisoryPushbacks(task, [{ pushback: result.pushback, who: "qa" }]);
|
|
1389
1455
|
if (result.relaxed) recordDecision(task, `QA round ${iterations} passed: ${result.relaxed}.`, "qa");
|
|
@@ -1558,6 +1624,12 @@ async function handleComplete(task: Task, params: OrchestrateParams, deps: Workf
|
|
|
1558
1624
|
// New lint errors in what the task touched hold it in block mode, unless the user accepted the work as it is.
|
|
1559
1625
|
const lint = await lintTask(task, deps);
|
|
1560
1626
|
if (lint && lintHolds(lint, deps.config.lint.mode, Boolean(task.lintWaiver))) throw new Error(`cannot complete: ${lintRefusal(lint)}`);
|
|
1627
|
+
const goal = await checkGoal(task, params, deps);
|
|
1628
|
+
if (goal.refusal) {
|
|
1629
|
+
// The refusal is recorded, so a reason on the next complete is an answer to it.
|
|
1630
|
+
saveTask(deps.root, deps.configDir, task);
|
|
1631
|
+
throw new Error(goal.refusal);
|
|
1632
|
+
}
|
|
1561
1633
|
// The gate refuses before any side effect, so a refusal leaves the task untouched.
|
|
1562
1634
|
const oversized = overThreshold(readDataRoots(deps.root, deps.configDir), deps.config.knowledge.compactionThreshold);
|
|
1563
1635
|
if (oversized.length > 0) throw new Error(`cannot complete: knowledge files over the compaction threshold: ${oversized.map((entry) => `${entry.agent}/${entry.file} (${entry.chars})`).join(", ")}. First record domain-relevant facts where they belong with action=knowledge (domain=designer|backend|qa), then rewrite each oversized file with action=compact, then call complete again.`);
|
|
@@ -1575,7 +1647,61 @@ async function handleComplete(task: Task, params: OrchestrateParams, deps: Workf
|
|
|
1575
1647
|
flushDecisions(deps, task);
|
|
1576
1648
|
const delivery = await offerDelivery(task, deps);
|
|
1577
1649
|
const left = lint?.state === "failing" ? ` ${task.lintWaiver ? "Accepted by the user with lint errors" : "Lint (advisory)"}: ${lintSummary(lint)} Tell the user.` : "";
|
|
1578
|
-
return `Task ${task.id} completed.${committed} History recorded and temporary scratchpads removed. ${delivery}${left}`;
|
|
1650
|
+
return `Task ${task.id} completed.${committed} History recorded and temporary scratchpads removed. ${delivery}${left}${goal.note ?? ""}`;
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
/**
|
|
1654
|
+
* The goal check before completion: one read-only agent compares the change
|
|
1655
|
+
* with the user's own request (not the plan). Gaps refuse completion once;
|
|
1656
|
+
* the oracle fixes them, or completes again with a `reason` when they are
|
|
1657
|
+
* deliberate (the user agreed). The same change is never checked twice, and
|
|
1658
|
+
* an unreadable check holds nothing.
|
|
1659
|
+
*/
|
|
1660
|
+
/** Enough of the change to tell one from another; the check itself reads a shorter cut. */
|
|
1661
|
+
const GOAL_HASH_CHARS = 4_000_000;
|
|
1662
|
+
|
|
1663
|
+
async function checkGoal(task: Task, params: OrchestrateParams, deps: WorkflowDeps): Promise<{ refusal?: string; note?: string }> {
|
|
1664
|
+
if (!deps.config.workflow.goalCheck || !builtSomething(task) || task.qaWaiver) return {};
|
|
1665
|
+
const base = await reviewBase(task, deps);
|
|
1666
|
+
// The whole change is hashed, so an edit past what the check reads still counts as a new change.
|
|
1667
|
+
const diff = await readRepositoryDiff(deps.cwd, { ...(base ? { base } : {}), exclude: ownRecords(deps), limitChars: GOAL_HASH_CHARS });
|
|
1668
|
+
if (!diff.trim() || /^Unable to read git state/.test(diff)) return {};
|
|
1669
|
+
const key = createHash("sha256").update(diff).digest("hex").slice(0, 16);
|
|
1670
|
+
if (task.goalCheck?.key !== key) {
|
|
1671
|
+
let result: Awaited<ReturnType<typeof runGoalCheck>> | undefined;
|
|
1672
|
+
// One more read when the first could not be read: a lost verdict should not let a gap through unseen.
|
|
1673
|
+
for (let attempt = 0; attempt < 2 && result?.verdict !== "met" && result?.verdict !== "not_met"; attempt++) {
|
|
1674
|
+
deps.signal?.throwIfAborted();
|
|
1675
|
+
result = await runGoalCheck({
|
|
1676
|
+
taskId: task.id,
|
|
1677
|
+
request: taskRequest(task),
|
|
1678
|
+
amendments: task.amendments,
|
|
1679
|
+
diff,
|
|
1680
|
+
cwd: deps.cwd,
|
|
1681
|
+
config: deps.config,
|
|
1682
|
+
...(deps.profile ? { profile: deps.profile } : {}),
|
|
1683
|
+
...(deps.signal ? { signal: deps.signal } : {}),
|
|
1684
|
+
...(deps.onUpdate ? { onUpdate: deps.onUpdate } : {}),
|
|
1685
|
+
}, deps.runProcess ?? spawnPiProcess);
|
|
1686
|
+
}
|
|
1687
|
+
deps.signal?.throwIfAborted();
|
|
1688
|
+
if (!result || result.verdict === "unread") {
|
|
1689
|
+
// Not recorded, so the next complete checks again.
|
|
1690
|
+
const why = result?.run.error ? `: ${truncate(result.run.error, 160)}` : "";
|
|
1691
|
+
return { note: ` The goal check could not be read (twice${why}), so the change was not checked against the request; tell the user.` };
|
|
1692
|
+
}
|
|
1693
|
+
task.goalCheck = { key, verdict: result.verdict, gaps: result.gaps, at: new Date().toISOString() };
|
|
1694
|
+
}
|
|
1695
|
+
const check = task.goalCheck!;
|
|
1696
|
+
if (check.verdict !== "not_met") return {};
|
|
1697
|
+
const gaps = check.gaps.slice(0, 12).map((gap) => `- ${truncate(gap, 300)}`).join("\n");
|
|
1698
|
+
// A reason answers a refusal the oracle has seen; given up front, it is not an answer to anything yet.
|
|
1699
|
+
if (params.reason?.trim() && check.refusedAt) {
|
|
1700
|
+
recordDecision(task, `Completed although the goal check named gaps (${check.gaps.map((gap) => truncate(gap, 120)).join("; ")}): ${params.reason.trim()}`);
|
|
1701
|
+
return { note: ` Completed with the goal check's gaps left open, because: ${truncate(params.reason.trim(), 300)}. Tell the user which parts of their request are not done:\n${gaps}` };
|
|
1702
|
+
}
|
|
1703
|
+
check.refusedAt = new Date().toISOString();
|
|
1704
|
+
return { refusal: `cannot complete: the goal check read the change against the user's request and found it does not meet all of it:\n${gaps}\nDelegate a fix step for each gap and complete again (the next check reads the new change). If a gap is deliberate (the user agreed to leave it out, or it is out of scope), call complete again with reason saying so; tell the user.` };
|
|
1579
1705
|
}
|
|
1580
1706
|
|
|
1581
1707
|
/** The files a task's own workers changed: not one that held changes before they started, nor another task's. */
|
|
@@ -1769,150 +1895,13 @@ function handleCancel(task: Task): string {
|
|
|
1769
1895
|
return `Task ${task.id} abandoned. Its scratchpad is kept until the task is formally resolved.`;
|
|
1770
1896
|
}
|
|
1771
1897
|
|
|
1772
|
-
/* ------------------------------------------------------------ time budget */
|
|
1773
|
-
|
|
1774
|
-
/** Shares of what is left that a scout batch and the researcher are given (within their configured limits). */
|
|
1775
|
-
const SCOUT_SHARE = 0.1;
|
|
1776
|
-
const RESEARCH_SHARE = 0.15;
|
|
1777
|
-
/** Minutes a worker out of time is taken to ask for when its report names none. */
|
|
1778
|
-
const DEFAULT_MORE_MINUTES = 10;
|
|
1779
|
-
|
|
1780
|
-
/** A delegation's time, with the allotment it is recorded under. */
|
|
1781
|
-
type WorkerTime = AgentTime & { id: string; finish?: () => void };
|
|
1782
|
-
|
|
1783
|
-
/** The task's budget and where it stands, when it has one. */
|
|
1784
|
-
function budgetFor(task: Task, deps: WorkflowDeps): { budget: TaskBudget; state: BudgetState } | undefined {
|
|
1785
|
-
const budget = readBudget(deps.root, deps.configDir, task.id);
|
|
1786
|
-
return budget ? { budget, state: budgetState(task.id, budget, !qaStillDue(task)) } : undefined;
|
|
1787
|
-
}
|
|
1788
|
-
|
|
1789
|
-
/** No new work starts once the budget is spent: the oracle asks the user for more, or wraps up. */
|
|
1790
|
-
function budgetSpent(state: BudgetState, what: string): Error {
|
|
1791
|
-
const reserve = state.reserveMs > 0 ? `, ${formatMinutes(state.reserveMs)} of it kept for the QA gate` : "";
|
|
1792
|
-
const wrap = state.reserveMs >= MIN_READ_MS ? "run action=qa on what is done" : "complete or block with what is done";
|
|
1793
|
-
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}.`);
|
|
1794
|
-
}
|
|
1795
|
-
|
|
1796
|
-
/** What an agent is told about its time. */
|
|
1797
|
-
function timeNote(allotMs: number, state: BudgetState, worker: boolean): string {
|
|
1798
|
-
return [
|
|
1799
|
-
"## Time",
|
|
1800
|
-
`You have ${formatMinutes(allotMs)} for this ${worker ? "step" : "work"}; the task has ${formatMinutes(state.leftMs)} of its ${formatMinutes(state.totalMs)} left.`,
|
|
1801
|
-
worker
|
|
1802
|
-
? "This is the total allowance shared across startup, retries, children, tools and reporting. At about 75% you get a heads-up. Before the hard deadline, stop and report where you left off (`## Left Off`) and how much more you need (`## More Time`); only the user can grant it. Land the most important part first, and keep every file consistent as you go."
|
|
1803
|
-
: "When the time is up you are asked to stop and report what you have, so cover the most important questions first.",
|
|
1804
|
-
].join("\n");
|
|
1805
|
-
}
|
|
1806
|
-
|
|
1807
|
-
function recordAllotment(task: Task, deps: WorkflowDeps, entry: Omit<Allotment, "startedAt" | "granted">): void {
|
|
1808
|
-
updateBudget(deps.root, deps.configDir, task.id, (budget) => {
|
|
1809
|
-
budget.allotments.push({ ...entry, granted: 0, startedAt: new Date().toISOString() });
|
|
1810
|
-
});
|
|
1811
|
-
}
|
|
1812
|
-
|
|
1813
|
-
function settleAllotment(task: Task, deps: WorkflowDeps, id: string, outcome: NonNullable<Allotment["outcome"]>): void {
|
|
1814
|
-
updateBudget(deps.root, deps.configDir, task.id, (budget) => {
|
|
1815
|
-
const entry = budget.allotments.find((allotment) => allotment.id === id);
|
|
1816
|
-
if (entry && !entry.endedAt) Object.assign(entry, { endedAt: new Date().toISOString(), outcome });
|
|
1817
|
-
});
|
|
1818
|
-
}
|
|
1819
|
-
|
|
1820
1898
|
function oneLine(text: string, max: number): string {
|
|
1821
1899
|
return truncate(text.replace(/\s+/g, " ").trim(), max).replace(/\n\[\.\.\.\d+ characters omitted\]$/, "…");
|
|
1822
1900
|
}
|
|
1823
1901
|
|
|
1824
|
-
/** A read-only batch's time (scouts, the researcher): a share of what is left, within its configured limit. */
|
|
1825
|
-
function readerTime(task: Task, deps: WorkflowDeps, who: string, what: string, share: number, limitMs: number, label: string): WorkerTime | undefined {
|
|
1826
|
-
const now = budgetFor(task, deps);
|
|
1827
|
-
if (!now) return undefined;
|
|
1828
|
-
const ms = readerAllotment(now.state, share, Math.min(STEP_LIMIT_MS, limitMs));
|
|
1829
|
-
if (ms === undefined) throw budgetSpent(now.state, label);
|
|
1830
|
-
const id = `${who.toLowerCase()}-${Date.now().toString(36)}`;
|
|
1831
|
-
recordAllotment(task, deps, { id, who, what: oneLine(what, 120), minutes: Math.round(ms / 60_000) });
|
|
1832
|
-
return { id, endsAt: Date.now() + ms, allotMs: ms, note: timeNote(ms, now.state, false) };
|
|
1833
|
-
}
|
|
1834
|
-
|
|
1835
|
-
/** The QA gate's time: its reserve at least, never more than is left. */
|
|
1836
|
-
function qaTime(task: Task, deps: WorkflowDeps): WorkerTime | undefined {
|
|
1837
|
-
const now = budgetFor(task, deps);
|
|
1838
|
-
if (!now) return undefined;
|
|
1839
|
-
const ms = qaAllotment(now.state, Math.min(STEP_LIMIT_MS, deps.config.agents.qa.timeoutMs ?? deps.config.workflow.agentTimeoutMs));
|
|
1840
|
-
if (ms === undefined) throw budgetSpent(now.state, "the QA gate");
|
|
1841
|
-
const id = `qa-${Date.now().toString(36)}`;
|
|
1842
|
-
recordAllotment(task, deps, { id, who: "QA gate", what: "review the task's work", minutes: Math.round(ms / 60_000) });
|
|
1843
|
-
return { id, endsAt: Date.now() + ms, allotMs: ms, note: timeNote(ms, now.state, false) };
|
|
1844
|
-
}
|
|
1845
|
-
|
|
1846
|
-
/** Plan domains still to build, the batch counted once: what is left before the QA reserve is split across them by default. */
|
|
1847
|
-
function openSlots(task: Task, batch: readonly Domain[]): number {
|
|
1848
|
-
const built = new Set((task.workerRuns ?? []).filter((run) => run.status === "success").map((run) => run.domain));
|
|
1849
|
-
return task.domains.filter((domain) => !built.has(domain) && !batch.includes(domain)).length + 1;
|
|
1850
|
-
}
|
|
1851
|
-
|
|
1852
|
-
/**
|
|
1853
|
-
* Each step's time, given before any starts: the minutes the oracle asked
|
|
1854
|
-
* for (it divides by scope) or an even share, never past what is left before
|
|
1855
|
-
* the QA gate's reserve. A spent budget starts nothing.
|
|
1856
|
-
*/
|
|
1857
|
-
async function workerTimes(task: Task, deps: WorkflowDeps, assignments: readonly Assignment[]): Promise<Map<Domain, WorkerTime>> {
|
|
1858
|
-
const times = new Map<Domain, WorkerTime>();
|
|
1859
|
-
const now = budgetFor(task, deps);
|
|
1860
|
-
const slots = openSlots(task, assignments.map((entry) => entry.domain));
|
|
1861
|
-
const steps = planSteps(task.plan ?? "");
|
|
1862
|
-
const completed = new Set(planChecklist(task.plan ?? "", (task.workerRuns ?? []).map((record) => ({ ...record, taskId: task.id, role: "worker" as const, output: "", attempts: 1 })), task.skippedSteps).flatMap((step, index) => step.status === "done" || step.status === "skipped" ? [index] : []));
|
|
1863
|
-
const prepared = [];
|
|
1864
|
-
for (const { domain, instruction, minutes } of assignments) {
|
|
1865
|
-
const covered = runSteps(steps, instruction, completed);
|
|
1866
|
-
const keys = covered.length ? covered.map((index) => `${domain}:step-${index + 1}`) : [`${domain}:unplanned`];
|
|
1867
|
-
const limitMs = Math.min(STEP_LIMIT_MS, profileFor(deps.config, deps.profile, domain, "worker").timeoutMs);
|
|
1868
|
-
let left = stepTimeLeft(deps.root, deps.configDir, task.id, keys, limitMs);
|
|
1869
|
-
if (left <= 0) {
|
|
1870
|
-
const wanted = DEFAULT_MORE_MINUTES;
|
|
1871
|
-
const granted = await decideMoreTime(task, deps, {
|
|
1872
|
-
label: AGENT_LABELS[domain], instruction, wanted,
|
|
1873
|
-
result: parseWorkerResult(domain, scratchpadSummary(deps, task, domain)),
|
|
1874
|
-
run: { runId: "", taskId: task.id, domain, role: "worker", status: "timeout", output: "", attempts: 1, startedAt: new Date().toISOString(), allotMs: limitMs },
|
|
1875
|
-
state: now?.state,
|
|
1876
|
-
});
|
|
1877
|
-
if (granted <= 0) throw new Error(`${AGENT_LABELS[domain]}'s step allowance is exhausted. Return the unfinished report; continuing requires user-approved time.`);
|
|
1878
|
-
grantStepTime(deps.root, deps.configDir, task.id, keys, limitMs, granted * 60_000);
|
|
1879
|
-
recordDecision(task, `The user gave ${AGENT_LABELS[domain]}'s exhausted step ${granted} more minutes.`);
|
|
1880
|
-
left = stepTimeLeft(deps.root, deps.configDir, task.id, keys, limitMs);
|
|
1881
|
-
}
|
|
1882
|
-
const given = now ? workerAllotment(now.state, minutes, slots) : { ms: left };
|
|
1883
|
-
if (!given) throw budgetSpent(now!.state, `the ${domain} step`);
|
|
1884
|
-
const ms = Math.min(left, given.ms);
|
|
1885
|
-
prepared.push({ domain, instruction, keys, limitMs, ms });
|
|
1886
|
-
}
|
|
1887
|
-
try {
|
|
1888
|
-
for (const { domain, instruction, keys, limitMs, ms } of prepared) {
|
|
1889
|
-
const reservation = reserveStepTime(deps.root, deps.configDir, task.id, keys, limitMs, ms);
|
|
1890
|
-
const id = `${domain}-${Date.now().toString(36)}`;
|
|
1891
|
-
let exhausted = false;
|
|
1892
|
-
const askMore = moreTimeFor(task, deps, domain, instruction, id, (extraMs) => reservation.grant(extraMs));
|
|
1893
|
-
recordAllotment(task, deps, { id, who: AGENT_LABELS[domain], what: oneLine(instruction, 120), minutes: Math.round(ms / 60_000) });
|
|
1894
|
-
const time: WorkerTime = { id, endsAt: Date.now() + ms, allotMs: ms, strict: true,
|
|
1895
|
-
note: now ? timeNote(ms, now.state, true) : `## Time\nYou have ${formatMinutes(ms)} total for this step, shared across retries and children. Report unfinished work before the deadline; only the user can grant more time.`,
|
|
1896
|
-
onTimeUp: async (run, report) => {
|
|
1897
|
-
const extraMs = await askMore(run, report);
|
|
1898
|
-
const result = parseWorkerResult(domain, report);
|
|
1899
|
-
if (extraMs <= 0 && (result.leftOff || result.moreTime)) exhausted = true;
|
|
1900
|
-
return extraMs;
|
|
1901
|
-
},
|
|
1902
|
-
};
|
|
1903
|
-
time.finish = () => reservation.finish(time.usedMs ?? ms, exhausted);
|
|
1904
|
-
times.set(domain, time);
|
|
1905
|
-
}
|
|
1906
|
-
} catch (error) {
|
|
1907
|
-
for (const time of times.values()) { time.usedMs = 0; time.finish?.(); }
|
|
1908
|
-
throw error;
|
|
1909
|
-
}
|
|
1910
|
-
return times;
|
|
1911
|
-
}
|
|
1912
|
-
|
|
1913
1902
|
const AGENT_LABELS: Record<Domain, string> = { backend: "DEV", designer: "DESIGN", qa: "QA" };
|
|
1914
1903
|
|
|
1915
|
-
/**
|
|
1904
|
+
/** Questions to the user one at a time, even when parallel workers ask together. */
|
|
1916
1905
|
let asking: Promise<unknown> = Promise.resolve();
|
|
1917
1906
|
function oneAtATime<T>(ask: () => Promise<T>): Promise<T> {
|
|
1918
1907
|
const next = asking.then(ask, ask);
|
|
@@ -1920,134 +1909,6 @@ function oneAtATime<T>(ask: () => Promise<T>): Promise<T> {
|
|
|
1920
1909
|
return next;
|
|
1921
1910
|
}
|
|
1922
1911
|
|
|
1923
|
-
/**
|
|
1924
|
-
* A worker out of time has reported what it did, where it left off and how
|
|
1925
|
-
* much more it needs. Only the user grants more time; a grant past the task budget grows it,
|
|
1926
|
-
* and the same agent carries on. Resolves with the ms granted, 0 to stop it.
|
|
1927
|
-
*/
|
|
1928
|
-
function moreTimeFor(task: Task, deps: WorkflowDeps, domain: Domain, instruction: string, id: string, onGrant?: (ms: number) => void): NonNullable<AgentTime["onTimeUp"]> {
|
|
1929
|
-
return (run, report) => oneAtATime(async () => {
|
|
1930
|
-
const result = parseWorkerResult(domain, report);
|
|
1931
|
-
// A report with nothing left to do finished in time.
|
|
1932
|
-
if (!result.leftOff && !result.moreTime) return 0;
|
|
1933
|
-
const label = AGENT_LABELS[domain];
|
|
1934
|
-
const wanted = result.moreTime?.minutes ?? DEFAULT_MORE_MINUTES;
|
|
1935
|
-
const now = budgetFor(task, deps);
|
|
1936
|
-
const minutes = await decideMoreTime(task, deps, { label, instruction, result, wanted, run, state: now?.state });
|
|
1937
|
-
if (minutes <= 0) return 0;
|
|
1938
|
-
const over = Math.max(0, Math.ceil((minutes * 60_000 - (now?.state.windowMs ?? minutes * 60_000)) / 60_000));
|
|
1939
|
-
updateBudget(deps.root, deps.configDir, task.id, (budget) => {
|
|
1940
|
-
budget.granted += over;
|
|
1941
|
-
const entry = budget.allotments.find((allotment) => allotment.id === id);
|
|
1942
|
-
if (entry) Object.assign(entry, { minutes: entry.minutes + minutes, granted: entry.granted + minutes });
|
|
1943
|
-
});
|
|
1944
|
-
recordDecision(task, `${label} ran out of time; 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)}`);
|
|
1945
|
-
onGrant?.(minutes * 60_000);
|
|
1946
|
-
return minutes * 60_000;
|
|
1947
|
-
});
|
|
1948
|
-
}
|
|
1949
|
-
|
|
1950
|
-
interface MoreTimeAsk {
|
|
1951
|
-
label: string;
|
|
1952
|
-
instruction: string;
|
|
1953
|
-
result: WorkerResult;
|
|
1954
|
-
wanted: number;
|
|
1955
|
-
run: AgentRun;
|
|
1956
|
-
state?: BudgetState;
|
|
1957
|
-
}
|
|
1958
|
-
|
|
1959
|
-
const MORE_TIME = "Give it the time it asks for";
|
|
1960
|
-
const OTHER_TIME = "Give a different amount";
|
|
1961
|
-
|
|
1962
|
-
async function decideMoreTime(task: Task, deps: WorkflowDeps, ask: MoreTimeAsk): Promise<number> {
|
|
1963
|
-
const { label, instruction, result, wanted, run, state } = ask;
|
|
1964
|
-
if (isAutoMode(deps.root, deps.configDir, task.id)) {
|
|
1965
|
-
recordDecision(task, `Auto mode: ${label}'s step allowance is spent. More time requires the user; return the unfinished report.`);
|
|
1966
|
-
return 0;
|
|
1967
|
-
}
|
|
1968
|
-
const over = Math.max(0, Math.ceil((wanted * 60_000 - (state?.windowMs ?? wanted * 60_000)) / 60_000));
|
|
1969
|
-
const had = run.allotMs ?? 0;
|
|
1970
|
-
const choice = await deps.choose(
|
|
1971
|
-
[
|
|
1972
|
-
`${label} is out of time: it had ${formatMinutes(had + (run.extendedMs ?? 0))} for "${oneLine(instruction, 160)}".`,
|
|
1973
|
-
result.completed ? `Done so far: ${oneLine(result.completed, 400)}` : "",
|
|
1974
|
-
result.leftOff ? `Left to do: ${oneLine(result.leftOff, 400)}` : "",
|
|
1975
|
-
`It needs about ${wanted} more minutes${result.moreTime?.reason ? `: ${oneLine(result.moreTime.reason, 200)}` : ""}.`,
|
|
1976
|
-
state ? `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` : ""}.` : "This step has a shared allowance even though the task has no overall time budget.",
|
|
1977
|
-
`Give ${label} ${wanted} more minutes to finish?`,
|
|
1978
|
-
].filter(Boolean).join("\n\n"),
|
|
1979
|
-
[MORE_TIME, OTHER_TIME, `Stop ${label} here`],
|
|
1980
|
-
);
|
|
1981
|
-
if (choice === MORE_TIME) return wanted;
|
|
1982
|
-
if (choice === OTHER_TIME) return parseMinutes((await deps.ask(`How many more minutes for ${label}?`)) ?? "") ?? 0;
|
|
1983
|
-
recordDecision(task, `The user stopped ${label} at its time limit. Left off: ${oneLine(result.leftOff ?? "", 200)}`);
|
|
1984
|
-
return 0;
|
|
1985
|
-
}
|
|
1986
|
-
|
|
1987
|
-
/** Stopped at its time with work left: a report that finished everything as time ran out is not. */
|
|
1988
|
-
function stoppedForTime(outcome: WorkerOutcome): boolean {
|
|
1989
|
-
return Boolean(outcome.run.status === "timeout" || (outcome.run.timeUp && (outcome.result.leftOff || outcome.result.moreTime)));
|
|
1990
|
-
}
|
|
1991
|
-
|
|
1992
|
-
/** How a step's time went, for the oracle: out of time and stopped, or given more. */
|
|
1993
|
-
function timeReport(outcome: WorkerOutcome): string {
|
|
1994
|
-
const { run, result } = outcome;
|
|
1995
|
-
const label = AGENT_LABELS[result.domain];
|
|
1996
|
-
if (stoppedForTime(outcome)) {
|
|
1997
|
-
return [
|
|
1998
|
-
`${label} ran out of its time and was not given more: this step is unfinished.`,
|
|
1999
|
-
result.leftOff ? `Left off: ${oneLine(result.leftOff, 600)}` : "",
|
|
2000
|
-
result.moreTime ? `It asked for ${result.moreTime.minutes ?? "more"} minutes${result.moreTime.reason ? `: ${oneLine(result.moreTime.reason, 300)}` : ""}.` : "",
|
|
2001
|
-
"Decide with the user: trim the scope, ask for task time with action=budget, or wrap up with what is done.",
|
|
2002
|
-
].filter(Boolean).join("\n");
|
|
2003
|
-
}
|
|
2004
|
-
return run.extendedMs ? `${label} ran out of its ${formatMinutes(run.allotMs ?? 0)} and was given ${formatMinutes(run.extendedMs)} more.` : "";
|
|
2005
|
-
}
|
|
2006
|
-
|
|
2007
|
-
/**
|
|
2008
|
-
* `action=budget`: with no minutes, where the budget stands; with minutes and
|
|
2009
|
-
* a reason, the oracle asks the user for more task time. Only the user can
|
|
2010
|
-
* grant it; in auto mode nobody can, and the task wraps up.
|
|
2011
|
-
*/
|
|
2012
|
-
async function handleBudget(task: Task, params: OrchestrateParams, deps: WorkflowDeps): Promise<string> {
|
|
2013
|
-
const now = budgetFor(task, deps);
|
|
2014
|
-
if (!now) return "This task has no time budget. The user sets one with /bot-lobby budget <minutes>.";
|
|
2015
|
-
const minutes = params.minutes;
|
|
2016
|
-
if (!minutes || minutes <= 0) return allotmentReport(now.budget);
|
|
2017
|
-
const reason = params.reason?.trim() || params.text?.trim();
|
|
2018
|
-
if (!reason) throw new Error("budget requires reason: why the task needs more time");
|
|
2019
|
-
if (isAutoMode(deps.root, deps.configDir, task.id)) {
|
|
2020
|
-
recordDecision(task, `Auto mode: asked for ${minutes} more minutes (${oneLine(reason, 160)}), which only the user can give.`);
|
|
2021
|
-
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.";
|
|
2022
|
-
}
|
|
2023
|
-
const choice = await deps.choose(
|
|
2024
|
-
[`The oracle asks for ${minutes} more minutes on ${task.id}: ${oneLine(reason, 400)}`, budgetLine(now.budget, now.state)].join("\n\n"),
|
|
2025
|
-
[`Give ${minutes} more minutes`, OTHER_TIME, "No"],
|
|
2026
|
-
);
|
|
2027
|
-
const granted = choice === `Give ${minutes} more minutes` ? minutes : choice === OTHER_TIME ? parseMinutes((await deps.ask(`How many more minutes for ${task.id}?`)) ?? "") ?? 0 : 0;
|
|
2028
|
-
if (granted <= 0) {
|
|
2029
|
-
recordDecision(task, `The user did not give ${minutes} more minutes: ${oneLine(reason, 200)}`);
|
|
2030
|
-
return "The user did not give more time. Wrap up with what is done and tell them what is left.";
|
|
2031
|
-
}
|
|
2032
|
-
updateBudget(deps.root, deps.configDir, task.id, (budget) => {
|
|
2033
|
-
budget.granted += granted;
|
|
2034
|
-
});
|
|
2035
|
-
recordDecision(task, `The user gave the task ${granted} more minutes: ${oneLine(reason, 200)}`);
|
|
2036
|
-
return `The user gave the task ${granted} more minutes.`;
|
|
2037
|
-
}
|
|
2038
|
-
|
|
2039
|
-
/** What each delegation was given, newest last. */
|
|
2040
|
-
function allotmentReport(budget: TaskBudget): string {
|
|
2041
|
-
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"}`);
|
|
2042
|
-
return rows.length > 0 ? `Allotted so far:\n${rows.join("\n")}` : "Nothing allotted yet.";
|
|
2043
|
-
}
|
|
2044
|
-
|
|
2045
|
-
/** Every orchestrate result ends with where the budget stands, when the task has one. */
|
|
2046
|
-
function budgetFooter(task: Task, deps: WorkflowDeps): string {
|
|
2047
|
-
const now = budgetFor(task, deps);
|
|
2048
|
-
return now ? `\n\n${budgetLine(now.budget, now.state)}` : "";
|
|
2049
|
-
}
|
|
2050
|
-
|
|
2051
1912
|
const HANDLERS: Record<OrchestrateAction, (task: Task, params: OrchestrateParams, deps: WorkflowDeps) => Promise<string> | string> = {
|
|
2052
1913
|
clarify: handleClarify,
|
|
2053
1914
|
scout: handleScout,
|
|
@@ -2064,7 +1925,6 @@ const HANDLERS: Record<OrchestrateAction, (task: Task, params: OrchestrateParams
|
|
|
2064
1925
|
block: handleBlock,
|
|
2065
1926
|
resume: handleResume,
|
|
2066
1927
|
decide: handleDecide,
|
|
2067
|
-
budget: handleBudget,
|
|
2068
1928
|
track: handleTrack,
|
|
2069
1929
|
status: (task) => describeTask(task),
|
|
2070
1930
|
cancel: handleCancel,
|
|
@@ -2150,11 +2010,11 @@ export async function runWorkflowAction(params: OrchestrateParams, deps: Workflo
|
|
|
2150
2010
|
execution.signal.throwIfAborted();
|
|
2151
2011
|
const runs = recordRunLog(task, finished, deps);
|
|
2152
2012
|
saveTask(deps.root, deps.configDir, task);
|
|
2153
|
-
return { ok: true, taskId: task.id, state: task.state, message: `${message}${runsFooter(runs)}
|
|
2013
|
+
return { ok: true, taskId: task.id, state: task.state, message: `${message}${runsFooter(runs)}`, runs };
|
|
2154
2014
|
} catch (error) {
|
|
2155
2015
|
const runs = recordRunLog(task, finished, deps);
|
|
2156
2016
|
saveTask(deps.root, deps.configDir, task);
|
|
2157
|
-
return { ok: false, taskId: task.id, state: task.state, message: `Rejected: ${(error as Error).message}
|
|
2017
|
+
return { ok: false, taskId: task.id, state: task.state, message: `Rejected: ${(error as Error).message}`, runs };
|
|
2158
2018
|
} finally {
|
|
2159
2019
|
execution.release();
|
|
2160
2020
|
for (const runId of working) noteWorkerRun(deps.root, deps.configDir, task.id, { runId, startedAt: "", status: "cancelled" });
|