@a-t-h-i/bot-lobby 0.6.38 → 0.6.39
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 +12 -5
- package/package.json +1 -1
- package/prompts/master.md +13 -2
- package/prompts/quickfix.md +5 -0
- package/prompts/worker.md +1 -0
- package/src/execution/git.ts +63 -0
- package/src/lobby/quickfix.ts +55 -2
- package/src/pi/plan-checklist.ts +2 -1
- package/src/pi/tools.ts +1 -0
- package/src/state/work-time.ts +4 -2
- package/src/webui/api/plan-facts.ts +17 -2
- package/src/webui/api/tasks.ts +1 -1
- package/src/webui/protocol.ts +10 -1
- package/src/workflow/workflow.ts +41 -2
- package/webui/dist/assets/index-D8TuTAMK.js +109 -0
- package/webui/dist/assets/index-uTndyUfm.css +2 -0
- package/webui/dist/build.json +14 -18
- package/webui/dist/index.html +2 -2
- package/webui/dist/assets/index-BFsMFIr5.js +0 -110
- package/webui/dist/assets/index-Ccc6rsPD.css +0 -2
package/README.md
CHANGED
|
@@ -23,8 +23,8 @@ The questionnaire and web tools are built in, so don't also install extensions w
|
|
|
23
23
|
A plan opens with a short list of steps; the detail of each follows for whoever wants it.
|
|
24
24
|
Plans you leave without saving them as a task wait under **Plan → Previous**. You can read, carry on,
|
|
25
25
|
archive or delete them there. A plan saved as a task leaves that list.
|
|
26
|
-
3. Follow the agents in **Lobby**: the
|
|
27
|
-
how long its agents have worked on it (idle time left out). Models, effort and time limits are in **Settings**.
|
|
26
|
+
3. Follow the agents in **Lobby** and **Tasks**: in a task's steps, the one being worked on reads
|
|
27
|
+
`active · 1m 12s`, and each task shows how long its agents have worked on it (idle time left out). Models, effort and time limits are in **Settings**.
|
|
28
28
|
|
|
29
29
|
**Issues** lists the repository's open issues (with `lobby.issues` on), and **Plan it** starts a
|
|
30
30
|
planning session from one. **Git** lists open pull requests; an agent reviews one on request,
|
|
@@ -36,6 +36,12 @@ you to confirm merging and pushing main, then removes its worktree after verifyi
|
|
|
36
36
|
Declining or failed verification keeps the worktree. Delivery can also be reviewed in **Tasks**,
|
|
37
37
|
where you can create a PR instead. The task branch is kept.
|
|
38
38
|
|
|
39
|
+
Workers never commit; the oracle does. When a task completes, the oracle writes a short commit
|
|
40
|
+
message (a first line of 72 characters or fewer, at most three lines below it). The engine then
|
|
41
|
+
commits only the files that task's workers changed. A quick fix commits its own files when it
|
|
42
|
+
finishes, under the one-line message its agent gives. Changes that were already there before, another
|
|
43
|
+
agent's and anything no agent made are left uncommitted.
|
|
44
|
+
|
|
39
45
|
New tasks get a short title summarizing the full request, which also names their branch and
|
|
40
46
|
session. Naming uses low reasoning on `classifier.effort.cheapModel` when configured, otherwise
|
|
41
47
|
the session model. If naming fails or takes more than eight seconds, the request's first content
|
|
@@ -159,9 +165,10 @@ including startup, tools, retries, children, compaction and reporting. Per-step
|
|
|
159
165
|
allowances persist across reassignments; only the user can extend them, including
|
|
160
166
|
in auto mode. Waiting for user answers pauses clocks.
|
|
161
167
|
|
|
162
|
-
|
|
163
|
-
**
|
|
164
|
-
|
|
168
|
+
In **Tasks**, a step being worked on shows how many agents are on it, for example
|
|
169
|
+
**Implement large feature (6 agents working)**. Click the count to see which agent is
|
|
170
|
+
doing what, with children under their parent. Queued and finished agents are left out,
|
|
171
|
+
and a child finishing does not complete a checklist step.
|
|
165
172
|
|
|
166
173
|
The oracle, agents and children compact at settled turn boundaries before context
|
|
167
174
|
reaches 64,000 estimated tokens, or earlier at 60% of the model window or Pi's
|
package/package.json
CHANGED
package/prompts/master.md
CHANGED
|
@@ -319,8 +319,8 @@ the task), or a worktree and branch of its own, and every agent works there.
|
|
|
319
319
|
Look at the task's files under the worktree path the line names, run git
|
|
320
320
|
there with `git -C "<path>"`, and never edit files outside it: the agents
|
|
321
321
|
already do. Uncommitted changes in the main checkout are not in it.
|
|
322
|
-
-
|
|
323
|
-
|
|
322
|
+
- `action=complete` commits the task's changes on its branch (see
|
|
323
|
+
Completion); leave unrelated changes alone. The engine asks
|
|
324
324
|
the user to confirm merging and pushing main and removing the task worktree.
|
|
325
325
|
Let it handle delivery; never merge or remove a worktree yourself.
|
|
326
326
|
- Without a `Git:` line, work as before.
|
|
@@ -433,6 +433,17 @@ fast track: QA has taken part when it is on the roster), critical blockers are
|
|
|
433
433
|
resolved, and relevant knowledge and decisions are recorded — never just
|
|
434
434
|
because a Worker says it is done.
|
|
435
435
|
|
|
436
|
+
You commit your agents' work, not they: workers never commit. Call
|
|
437
|
+
`action=complete` with `message`, a commit message you write from what was
|
|
438
|
+
built, and the engine commits exactly the files this task's workers changed
|
|
439
|
+
(never ones that held changes before they started, a quick fix's alone, or
|
|
440
|
+
another task's) before calling the task done. Keep it concise: a first line of
|
|
441
|
+
72 characters or fewer saying what changed, in the imperative ("Add login
|
|
442
|
+
rate limit"), and at most three short lines below it when the why is not
|
|
443
|
+
obvious. No file lists, step-by-step narration, test counts or sign-offs. A
|
|
444
|
+
refused commit (a hook failing, say) leaves the task open: fix the cause and
|
|
445
|
+
complete again.
|
|
446
|
+
|
|
436
447
|
For an isolated task, `action=complete` automatically reviews delivery and
|
|
437
448
|
asks the user before merging to main and removing its worktree, even in auto
|
|
438
449
|
mode. A decline, missing answer or failed verification preserves the worktree
|
package/prompts/quickfix.md
CHANGED
|
@@ -58,3 +58,8 @@ One or two sentences on what changed.
|
|
|
58
58
|
|
|
59
59
|
## Checked
|
|
60
60
|
What you ran and the result, or "not checked" with the reason.
|
|
61
|
+
|
|
62
|
+
## Commit
|
|
63
|
+
The commit message for your change: one line of 72 characters or fewer
|
|
64
|
+
saying what changed, in the imperative ("Fix the login button's focus ring").
|
|
65
|
+
The engine commits your files with it; do not commit, stage or stash yourself.
|
package/prompts/worker.md
CHANGED
|
@@ -99,6 +99,7 @@ repository at the same time, and files are checked out like physical documents:
|
|
|
99
99
|
nothing else.
|
|
100
100
|
- Verify tests.
|
|
101
101
|
- Update the temporary task scratchpad.
|
|
102
|
+
- Do not commit, stage or stash: the oracle commits the task's work.
|
|
102
103
|
- Report concise results.
|
|
103
104
|
|
|
104
105
|
## Pushback
|
package/src/execution/git.ts
CHANGED
|
@@ -214,3 +214,66 @@ export async function addedLines(top: string, base: string | undefined, files: r
|
|
|
214
214
|
return undefined;
|
|
215
215
|
}
|
|
216
216
|
}
|
|
217
|
+
|
|
218
|
+
/** Longest first line a commit message may have. */
|
|
219
|
+
export const MAX_COMMIT_SUBJECT = 72;
|
|
220
|
+
/** Most lines a commit message may add below its first. */
|
|
221
|
+
export const MAX_COMMIT_BODY_LINES = 3;
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* A commit message kept short: one first line of at most
|
|
225
|
+
* `MAX_COMMIT_SUBJECT` characters, then, after a blank line, at most
|
|
226
|
+
* `MAX_COMMIT_BODY_LINES` short lines. Throws saying what is too long.
|
|
227
|
+
*/
|
|
228
|
+
export function commitMessage(text: string): string {
|
|
229
|
+
const lines = text.replace(/\r\n?/g, "\n").trim().split("\n").map((line) => line.trimEnd());
|
|
230
|
+
const subject = lines[0]?.trim() ?? "";
|
|
231
|
+
if (!subject) throw new Error("the commit message is empty");
|
|
232
|
+
if (subject.length > MAX_COMMIT_SUBJECT) throw new Error(`the commit message's first line is ${subject.length} characters; keep it to ${MAX_COMMIT_SUBJECT} or fewer, saying what changed`);
|
|
233
|
+
const body = lines.slice(1).filter((line) => line.trim().length > 0);
|
|
234
|
+
if (body.length > MAX_COMMIT_BODY_LINES) throw new Error(`the commit message has ${body.length} lines below its first; keep it to ${MAX_COMMIT_BODY_LINES} or fewer`);
|
|
235
|
+
if (body.some((line) => line.trim().length > MAX_COMMIT_SUBJECT)) throw new Error(`keep each line of the commit message to ${MAX_COMMIT_SUBJECT} characters or fewer`);
|
|
236
|
+
return body.length > 0 ? `${subject}\n\n${body.map((line) => line.trim()).join("\n")}` : subject;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** `git status` of `files` (relative to `top`): each changed path with its two-letter code, a rename's old path beside its new one. */
|
|
240
|
+
async function statusOf(top: string, files: readonly string[]): Promise<Array<{ path: string; code: string }>> {
|
|
241
|
+
if (files.length === 0) return [];
|
|
242
|
+
const { stdout } = await run("git", ["status", "--porcelain", "-z", "--untracked-files=all", "--", ...files], { cwd: top, maxBuffer: MAX_BUFFER });
|
|
243
|
+
const entries = stdout.split("\0");
|
|
244
|
+
const out: Array<{ path: string; code: string }> = [];
|
|
245
|
+
for (let index = 0; index < entries.length; index += 1) {
|
|
246
|
+
const entry = entries[index]!;
|
|
247
|
+
if (entry.length < 4) continue;
|
|
248
|
+
const code = entry.slice(0, 2);
|
|
249
|
+
out.push({ path: entry.slice(3), code });
|
|
250
|
+
if (/[RC]/.test(code) && entries[index + 1]) out.push({ path: entries[++index]!, code: "D " });
|
|
251
|
+
}
|
|
252
|
+
return out;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Which of `files` (relative to `top`) have changes git has not committed, with a rename's old path beside its new one. */
|
|
256
|
+
export async function uncommitted(top: string, files: readonly string[]): Promise<string[]> {
|
|
257
|
+
return [...new Set((await statusOf(top, files)).map((entry) => entry.path))];
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Commit `files` (relative to `top`) as they stand, and nothing else: what
|
|
262
|
+
* else is staged stays staged. The commit, or undefined when none of them
|
|
263
|
+
* has anything to commit. Throws with git's reason when it refuses (a hook).
|
|
264
|
+
*/
|
|
265
|
+
export async function commitFiles(top: string, files: readonly string[], message: string): Promise<{ commit: string; files: string[] } | undefined> {
|
|
266
|
+
const status = await statusOf(top, files);
|
|
267
|
+
const dirty = [...new Set(status.map((entry) => entry.path))];
|
|
268
|
+
if (dirty.length === 0) return undefined;
|
|
269
|
+
// Only what the working tree holds beyond the index needs adding; a staged deletion is no longer there to add.
|
|
270
|
+
const unstaged = [...new Set(status.filter((entry) => entry.code[1] !== " ").map((entry) => entry.path))];
|
|
271
|
+
try {
|
|
272
|
+
if (unstaged.length > 0) await run("git", ["add", "-A", "--", ...unstaged], { cwd: top, maxBuffer: MAX_BUFFER });
|
|
273
|
+
await run("git", ["commit", "-q", "-m", message, "--", ...dirty], { cwd: top, maxBuffer: MAX_BUFFER });
|
|
274
|
+
} catch (error) {
|
|
275
|
+
const failure = error as { stderr?: string; stdout?: string; message: string };
|
|
276
|
+
throw new Error(truncate((failure.stderr || failure.stdout || failure.message).trim(), 600));
|
|
277
|
+
}
|
|
278
|
+
return { commit: await git(top, ["rev-parse", "HEAD"]), files: dirty };
|
|
279
|
+
}
|
package/src/lobby/quickfix.ts
CHANGED
|
@@ -21,7 +21,7 @@ import { fellShort, profileLabel, routeLabel, type EffortRoute, type EffortRoute
|
|
|
21
21
|
import { existsSync } from "node:fs";
|
|
22
22
|
import { isAbsolute, join, relative } from "node:path";
|
|
23
23
|
import type { LintConfig } from "../schemas/configuration.ts";
|
|
24
|
-
import { repositoryTop } from "../execution/git.ts";
|
|
24
|
+
import { changedFiles, commitFiles, commitMessage, MAX_COMMIT_SUBJECT, repositoryTop } from "../execution/git.ts";
|
|
25
25
|
import type { LintExec } from "../execution/lint.ts";
|
|
26
26
|
import { scriptsFor, type ScriptPicker } from "../scripts/pick.ts";
|
|
27
27
|
import { dataRoot } from "../state/project.ts";
|
|
@@ -72,6 +72,10 @@ export interface QuickFixJob {
|
|
|
72
72
|
routed?: boolean;
|
|
73
73
|
/** An earlier run was cut off when pi stopped: some of its edits may already be in the tree. */
|
|
74
74
|
interrupted?: boolean;
|
|
75
|
+
/** Files (relative to the repository) that had uncommitted changes before its first run: never its to commit. */
|
|
76
|
+
before?: string[];
|
|
77
|
+
/** The commit its changes went in, and its message's first line. */
|
|
78
|
+
commit?: { sha: string; subject: string };
|
|
75
79
|
}
|
|
76
80
|
|
|
77
81
|
/** How a job enters the queue besides its prompt. */
|
|
@@ -136,6 +140,24 @@ export function quickFixPrompt(instructions?: string): string {
|
|
|
136
140
|
return custom ? `${loadPrompt("quickfix.md")}\n\n## Custom Instructions\n\n${custom}` : loadPrompt("quickfix.md");
|
|
137
141
|
}
|
|
138
142
|
|
|
143
|
+
/**
|
|
144
|
+
* The commit message a quick fix's report gives under `## Commit`, kept short
|
|
145
|
+
* (see `commitMessage`); the request's first line, clipped, when it gives none
|
|
146
|
+
* or one too long.
|
|
147
|
+
*/
|
|
148
|
+
export function quickFixCommitMessage(job: Pick<QuickFixJob, "prompt" | "report">): string {
|
|
149
|
+
const section = /^## Commit[^\n]*\n([\s\S]*?)(?=^## |(?![\s\S]))/m.exec(job.report ?? "")?.[1]?.replace(/^\s*```[^\n]*\n?|```\s*$/g, "").trim();
|
|
150
|
+
if (section) {
|
|
151
|
+
try {
|
|
152
|
+
return commitMessage(section);
|
|
153
|
+
} catch {
|
|
154
|
+
// Too long or empty: the request names it instead.
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const title = jobTitle(job).replace(/\s+/g, " ");
|
|
158
|
+
return title.length > MAX_COMMIT_SUBJECT ? `${title.slice(0, MAX_COMMIT_SUBJECT - 1).trimEnd()}…` : title;
|
|
159
|
+
}
|
|
160
|
+
|
|
139
161
|
/** First line of a prompt, for lists. */
|
|
140
162
|
export function jobTitle(job: Pick<QuickFixJob, "prompt">): string {
|
|
141
163
|
return job.prompt.split("\n").find((line) => line.trim())?.trim() ?? "(empty)";
|
|
@@ -301,6 +323,8 @@ export class QuickFixQueue {
|
|
|
301
323
|
}
|
|
302
324
|
this.deps.feed?.log(QUICK_FIX_SOURCE, `started: ${jobTitle(job)}`, "info", job.startedAt);
|
|
303
325
|
this.edits.set(job.id, new EditLog(this.deps.cwd));
|
|
326
|
+
// What was uncommitted before it ran is someone else's; a run cut off and resumed keeps its first look.
|
|
327
|
+
job.before ??= (await changedFiles(this.deps.cwd))?.files ?? [];
|
|
304
328
|
try {
|
|
305
329
|
const [likely, route, scripts] = await Promise.all([
|
|
306
330
|
this.likely(job.prompt, controller.signal),
|
|
@@ -363,6 +387,12 @@ export class QuickFixQueue {
|
|
|
363
387
|
job.error = result.error;
|
|
364
388
|
job.usage = result.usage;
|
|
365
389
|
if (result.model) job.model = result.model;
|
|
390
|
+
const edited = this.edits.get(job.id)?.list() ?? [];
|
|
391
|
+
if (job.status === "success" && edited.length > 0) {
|
|
392
|
+
// Lint reads what changed since HEAD, so it goes before the commit.
|
|
393
|
+
await this.lintAfter(job, edited);
|
|
394
|
+
await this.commitAfter(job, edited);
|
|
395
|
+
}
|
|
366
396
|
} catch (error) {
|
|
367
397
|
job.status = "failed";
|
|
368
398
|
job.error = (error as Error).message;
|
|
@@ -440,11 +470,34 @@ export class QuickFixQueue {
|
|
|
440
470
|
this.deps.feed?.log(QUICK_FIX_SOURCE, `${outcome}: ${jobTitle(job)}`, ok ? "success" : job.status === "cancelled" ? "warning" : "error", job.finishedAt);
|
|
441
471
|
appendMetrics(this.deps.root, this.deps.configDir, [quickFixMetric(job)]);
|
|
442
472
|
this.deps.notify?.(`bot-lobby quick fix ${outcome}: ${jobTitle(job)}`, ok ? "info" : "warning");
|
|
443
|
-
if (ok && edited.length > 0) void this.lintAfter(job, edited);
|
|
444
473
|
this.changed();
|
|
445
474
|
this.pump();
|
|
446
475
|
}
|
|
447
476
|
|
|
477
|
+
/**
|
|
478
|
+
* Commit what a finished quick fix edited, with the message its report
|
|
479
|
+
* gives: only its own files, never one that held uncommitted changes before
|
|
480
|
+
* it ran. A refused commit is a line in the activity log; the fix stands.
|
|
481
|
+
*/
|
|
482
|
+
private async commitAfter(job: QuickFixJob, edited: readonly string[]): Promise<void> {
|
|
483
|
+
const top = await repositoryTop(this.deps.cwd);
|
|
484
|
+
if (!top) return;
|
|
485
|
+
const before = new Set(job.before ?? []);
|
|
486
|
+
const files = edited.map((file) => relative(top, file).split("\\").join("/")).filter((file) => file && !file.startsWith("..") && !isAbsolute(file) && !before.has(file));
|
|
487
|
+
const own = relative(top, dataRoot(this.deps.root, this.deps.configDir)).split("\\").join("/");
|
|
488
|
+
const ours = own && !own.startsWith("..") ? files.filter((file) => !file.startsWith(`${own}/`)) : files;
|
|
489
|
+
if (ours.length === 0) return;
|
|
490
|
+
const message = quickFixCommitMessage(job);
|
|
491
|
+
try {
|
|
492
|
+
const commit = await commitFiles(top, ours, message);
|
|
493
|
+
if (!commit) return;
|
|
494
|
+
job.commit = { sha: commit.commit, subject: message.split("\n")[0]! };
|
|
495
|
+
this.deps.feed?.log(QUICK_FIX_SOURCE, `committed ${commit.commit.slice(0, 7)}: ${job.commit.subject}`, "info");
|
|
496
|
+
} catch (error) {
|
|
497
|
+
this.deps.feed?.log(QUICK_FIX_SOURCE, `not committed: ${(error as Error).message.split("\n")[0]}`, "warning");
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
448
501
|
/**
|
|
449
502
|
* The lint gate's pass over what a quick fix touched (only problems on the
|
|
450
503
|
* lines it changed are its own): a line in the activity log, never a hold,
|
package/src/pi/plan-checklist.ts
CHANGED
|
@@ -438,7 +438,8 @@ export function stepWork(steps: readonly string[], runs: readonly AgentRun[], no
|
|
|
438
438
|
const ms = Number.isFinite(start) && Number.isFinite(end) ? Math.max(0, end - start) : 0;
|
|
439
439
|
for (const index of covered) {
|
|
440
440
|
if (!run.parentRunId) work[index]!.ms += ms;
|
|
441
|
-
|
|
441
|
+
// A run that has finished is off the step, whatever its last status said.
|
|
442
|
+
if (run.status === "running" && !run.finishedAt) {
|
|
442
443
|
work[index]!.active = true;
|
|
443
444
|
work[index]!.activeAgentCount += 1;
|
|
444
445
|
work[index]!.runIds.push(run.runId);
|
package/src/pi/tools.ts
CHANGED
|
@@ -60,6 +60,7 @@ const OrchestrateSchema = Type.Object({
|
|
|
60
60
|
),
|
|
61
61
|
roster: Type.Optional(Type.Array(Type.String(), { description: "track: who takes part — any of designer, backend, qa, researcher" })),
|
|
62
62
|
text: Type.Optional(Type.String({ description: "decide/complete/knowledge/script: the decision, completion summary, knowledge text, or the script itself" })),
|
|
63
|
+
message: Type.Optional(Type.String({ description: "complete: the commit message for the agents' changes, which the engine commits (only their files): a first line of 72 characters or fewer saying what changed, at most three short lines below it" })),
|
|
63
64
|
file: Type.Optional(Type.String({ description: "compact: the knowledge file to rewrite, e.g. knowledge.md; script: a script a worker drafted, to save instead of text" })),
|
|
64
65
|
name: Type.Optional(Type.String({ description: "whiteboard: name for the new Excalidraw session; script: the file name, what it does in snake_case with its language's extension (seed_public_holidays.py, run_changed_tests.sh)" })),
|
|
65
66
|
purpose: Type.Optional(Type.String({ description: "script: one line on what the script does" })),
|
package/src/state/work-time.ts
CHANGED
|
@@ -16,6 +16,8 @@ import { taskDirFor } from "./persistence.ts";
|
|
|
16
16
|
export interface ActiveRun {
|
|
17
17
|
runId: string;
|
|
18
18
|
parentRunId?: string;
|
|
19
|
+
/** The worker's domain (`backend`, `designer`), for who is doing what. */
|
|
20
|
+
domain?: string;
|
|
19
21
|
stepInstruction?: string;
|
|
20
22
|
instruction: string;
|
|
21
23
|
startedAt: string;
|
|
@@ -104,7 +106,7 @@ export function taskWork(root: string, configDir: string, taskId: string, now =
|
|
|
104
106
|
const noted = new Map<string, Set<string>>();
|
|
105
107
|
|
|
106
108
|
/** A worker run of the task started or ended: the steps it works on read as active while it runs. */
|
|
107
|
-
export function noteWorkerRun(root: string, configDir: string, taskId: string, run: { runId: string; parentRunId?: string; stepInstruction?: string; instruction?: string; startedAt: string; status: string }): void {
|
|
109
|
+
export function noteWorkerRun(root: string, configDir: string, taskId: string, run: { runId: string; parentRunId?: string; stepInstruction?: string; instruction?: string; domain?: string; startedAt: string; status: string }): void {
|
|
108
110
|
const running = run.status === "running";
|
|
109
111
|
const ids = noted.get(taskId) ?? new Set<string>();
|
|
110
112
|
if (running === ids.has(run.runId)) return;
|
|
@@ -113,7 +115,7 @@ export function noteWorkerRun(root: string, configDir: string, taskId: string, r
|
|
|
113
115
|
noted.set(taskId, ids);
|
|
114
116
|
updateWork(root, configDir, taskId, (work) => {
|
|
115
117
|
work.active = work.active.filter((entry) => entry.runId !== run.runId);
|
|
116
|
-
if (running) work.active.push({ runId: run.runId, instruction: run.instruction ?? "", startedAt: run.startedAt, ...(run.parentRunId ? { parentRunId: run.parentRunId } : {}), ...(run.stepInstruction ? { stepInstruction: run.stepInstruction } : {}) });
|
|
118
|
+
if (running) work.active.push({ runId: run.runId, instruction: run.instruction ?? "", startedAt: run.startedAt, ...(run.parentRunId ? { parentRunId: run.parentRunId } : {}), ...(run.domain ? { domain: run.domain } : {}), ...(run.stepInstruction ? { stepInstruction: run.stepInstruction } : {}) });
|
|
117
119
|
});
|
|
118
120
|
}
|
|
119
121
|
|
|
@@ -30,7 +30,7 @@ function activeRuns(task: Task, work: WorkProjection | undefined): AgentRun[] {
|
|
|
30
30
|
const finished = new Set((task.workerRuns ?? []).map((record) => record.runId));
|
|
31
31
|
return (work?.active ?? [])
|
|
32
32
|
.filter((run) => !finished.has(run.runId))
|
|
33
|
-
.map((run) => ({ runId: run.runId, parentRunId: run.parentRunId, stepInstruction: run.stepInstruction, taskId: task.id, domain: "backend" as
|
|
33
|
+
.map((run) => ({ runId: run.runId, parentRunId: run.parentRunId, stepInstruction: run.stepInstruction, taskId: task.id, domain: (run.domain === "designer" || run.domain === "qa" ? run.domain : "backend") as AgentRun["domain"], role: "worker" as const, status: "running" as const, instruction: run.instruction, output: "", attempts: 1, startedAt: run.startedAt }));
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
/** One step as the lobby shows it: its state, and while a worker is on it, how long they have worked on it. */
|
|
@@ -40,10 +40,20 @@ export interface StepView {
|
|
|
40
40
|
active: boolean;
|
|
41
41
|
activeAgentCount: number;
|
|
42
42
|
runIds: string[];
|
|
43
|
+
/** The agents on it right now: who, doing what, since when; a child names its parent. */
|
|
44
|
+
agents: StepAgent[];
|
|
43
45
|
/** Worker time on the step, in ms. */
|
|
44
46
|
workedMs: number;
|
|
45
47
|
}
|
|
46
48
|
|
|
49
|
+
export interface StepAgent {
|
|
50
|
+
runId: string;
|
|
51
|
+
parentRunId?: string;
|
|
52
|
+
domain: string;
|
|
53
|
+
instruction: string;
|
|
54
|
+
startedAt: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
47
57
|
/**
|
|
48
58
|
* The plan's steps with their state, which ones a worker is on right now and
|
|
49
59
|
* the worker time on each. A step being worked on is never done, so steps run
|
|
@@ -54,8 +64,13 @@ export function stepViews(task: Task, work: WorkProjection | undefined, now = Da
|
|
|
54
64
|
const runs = [...persistedTaskRuns(task), ...activeRuns(task, work)];
|
|
55
65
|
const checklist = planChecklist(task.plan, runs);
|
|
56
66
|
const times = stepWork(planSteps(task.plan), runs, now);
|
|
67
|
+
const byId = new Map(runs.map((run) => [run.runId, run]));
|
|
57
68
|
return checklist.map((step, index) => {
|
|
58
69
|
const time = times[index] ?? { ms: 0, active: false, activeAgentCount: 0, runIds: [] };
|
|
59
|
-
|
|
70
|
+
const agents = time.runIds.flatMap((runId) => {
|
|
71
|
+
const run = byId.get(runId);
|
|
72
|
+
return run ? [{ runId, ...(run.parentRunId ? { parentRunId: run.parentRunId } : {}), domain: run.domain, instruction: (run.instruction ?? "").split("\n")[0]!.trim(), startedAt: run.startedAt }] : [];
|
|
73
|
+
});
|
|
74
|
+
return { text: step.text, status: time.active ? "current" : step.status, active: time.active, activeAgentCount: time.activeAgentCount, runIds: time.runIds, agents, workedMs: Math.round(time.ms) };
|
|
60
75
|
});
|
|
61
76
|
}
|
package/src/webui/api/tasks.ts
CHANGED
|
@@ -311,7 +311,7 @@ export function tasksGet(body: { taskId: string }, ctx: ApiContext): TaskDetail
|
|
|
311
311
|
steps: stepViews(found, work, now).map((step) => ({
|
|
312
312
|
text: step.text,
|
|
313
313
|
status: step.status === "pending" ? "open" : step.status,
|
|
314
|
-
...(step.active ? { active: true, workedMs: step.workedMs, activeAgentCount: step.activeAgentCount } : {}),
|
|
314
|
+
...(step.active ? { active: true, workedMs: step.workedMs, activeAgentCount: step.activeAgentCount, agents: step.agents } : {}),
|
|
315
315
|
})),
|
|
316
316
|
amendments: [...found.amendments],
|
|
317
317
|
waiting: waitingOf(found),
|
package/src/webui/protocol.ts
CHANGED
|
@@ -420,7 +420,7 @@ export interface TaskDetail {
|
|
|
420
420
|
* The plan as a checklist, in order; `current` is the step under way, `active` the steps a worker is on right now,
|
|
421
421
|
* each with its worker time (ms, as of the reply).
|
|
422
422
|
*/
|
|
423
|
-
steps: Array<{ text: string; status: "done" | "current" | "open"; active?: boolean; workedMs?: number; activeAgentCount?: number }>;
|
|
423
|
+
steps: Array<{ text: string; status: "done" | "current" | "open"; active?: boolean; workedMs?: number; activeAgentCount?: number; agents?: StepAgentInfo[] }>;
|
|
424
424
|
/** How long the task's agents have worked on it, idle time left out (ms, as of the reply); `running` while they work. */
|
|
425
425
|
work?: WorkClock;
|
|
426
426
|
amendments: string[];
|
|
@@ -676,3 +676,12 @@ export interface Api {
|
|
|
676
676
|
}
|
|
677
677
|
|
|
678
678
|
export type ApiName = keyof Api;
|
|
679
|
+
|
|
680
|
+
/** An agent on a step right now: its domain, what it was told (first line), since when; a child names its parent. */
|
|
681
|
+
export interface StepAgentInfo {
|
|
682
|
+
runId: string;
|
|
683
|
+
parentRunId?: string;
|
|
684
|
+
domain: string;
|
|
685
|
+
instruction: string;
|
|
686
|
+
startedAt: string;
|
|
687
|
+
}
|
package/src/workflow/workflow.ts
CHANGED
|
@@ -32,7 +32,7 @@ import { mapConcurrent } from "../execution/agent-runner.ts";
|
|
|
32
32
|
import { parseWorkerResult } from "../roles/worker.ts";
|
|
33
33
|
import { autoNote, DESK_TOOLS, DeskSession } from "../desk/session.ts";
|
|
34
34
|
import type { Handover } from "../desk/desk.ts";
|
|
35
|
-
import { changedFiles, commitBefore, headCommit, readRepositoryDiff } from "../execution/git.ts";
|
|
35
|
+
import { changedFiles, commitBefore, commitFiles, commitMessage, headCommit, readRepositoryDiff, uncommitted } from "../execution/git.ts";
|
|
36
36
|
import { appendChange, explainChanges, provenanceLines, provenanceSummary, readChanges, type FileProvenance } from "../state/changes.ts";
|
|
37
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
38
|
import type { AgentTime } from "../execution/agent-runner.ts";
|
|
@@ -141,6 +141,8 @@ export interface OrchestrateParams {
|
|
|
141
141
|
note?: string;
|
|
142
142
|
reason?: string;
|
|
143
143
|
text?: string;
|
|
144
|
+
/** complete: the commit message for the agents' changes. */
|
|
145
|
+
message?: string;
|
|
144
146
|
/** track: the path the task takes. */
|
|
145
147
|
track?: "fast" | "full";
|
|
146
148
|
/** track: who takes part (designer, backend, qa, researcher). */
|
|
@@ -1570,6 +1572,7 @@ async function handleComplete(task: Task, params: OrchestrateParams, deps: Workf
|
|
|
1570
1572
|
// The gate refuses before any side effect, so a refusal leaves the task untouched.
|
|
1571
1573
|
const oversized = overThreshold(readDataRoots(deps.root, deps.configDir), deps.config.knowledge.compactionThreshold);
|
|
1572
1574
|
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
|
+
const committed = await commitTaskWork(task, params, deps);
|
|
1573
1576
|
const summary = params.text?.trim() || task.proposal || task.title;
|
|
1574
1577
|
recordCompletion(deps, task, summary);
|
|
1575
1578
|
removeTaskScratchpads(deps.root, deps.configDir, task.id);
|
|
@@ -1581,7 +1584,43 @@ async function handleComplete(task: Task, params: OrchestrateParams, deps: Workf
|
|
|
1581
1584
|
flushDecisions(deps, task);
|
|
1582
1585
|
const delivery = await offerDelivery(task, deps);
|
|
1583
1586
|
const left = lint?.state === "failing" ? ` ${task.lintWaiver ? "Accepted by the user with lint errors" : "Lint (advisory)"}: ${lintSummary(lint)} Tell the user.` : "";
|
|
1584
|
-
return `Task ${task.id} completed
|
|
1587
|
+
return `Task ${task.id} completed.${committed} History recorded and temporary scratchpads removed. ${delivery}${left}`;
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
/** The files a task's own workers changed: not one that held changes before they started, nor another task's. */
|
|
1591
|
+
function agentsWork(files: readonly FileProvenance[]): string[] {
|
|
1592
|
+
return files.filter((file) => file.kinds.includes("planned") && !file.kinds.includes("pre-existing") && !file.kinds.includes("other-task")).map((file) => file.path);
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
/**
|
|
1596
|
+
* The oracle commits what its agents changed before the task is called done,
|
|
1597
|
+
* with its own short message (`message`); nothing else in the tree is
|
|
1598
|
+
* staged or committed. "" when git is not in use or nothing is left to commit.
|
|
1599
|
+
*/
|
|
1600
|
+
async function commitTaskWork(task: Task, params: OrchestrateParams, deps: WorkflowDeps): Promise<string> {
|
|
1601
|
+
const state = await changeState(deps, task, await reviewBase(task, deps));
|
|
1602
|
+
if (!state) return "";
|
|
1603
|
+
const ours = agentsWork(state.files);
|
|
1604
|
+
const dirty = await uncommitted(state.top, ours).catch(() => []);
|
|
1605
|
+
if (dirty.length === 0) return "";
|
|
1606
|
+
const listed = dirty.slice(0, 20).join(", ") + (dirty.length > 20 ? ` and ${dirty.length - 20} more` : "");
|
|
1607
|
+
if (!params.message?.trim()) throw new Error(`cannot complete: commit the agents' changes first. Call complete again with message: a concise commit message for them (a first line of 72 characters or fewer saying what changed, at most three short lines below it). The engine commits only these files: ${listed}`);
|
|
1608
|
+
let message: string;
|
|
1609
|
+
try {
|
|
1610
|
+
message = commitMessage(params.message);
|
|
1611
|
+
} catch (error) {
|
|
1612
|
+
throw new Error(`cannot complete: ${(error as Error).message}`);
|
|
1613
|
+
}
|
|
1614
|
+
let result: Awaited<ReturnType<typeof commitFiles>>;
|
|
1615
|
+
try {
|
|
1616
|
+
result = await commitFiles(state.top, dirty, message);
|
|
1617
|
+
} catch (error) {
|
|
1618
|
+
throw new Error(`cannot complete: git refused the commit: ${(error as Error).message}`);
|
|
1619
|
+
}
|
|
1620
|
+
if (!result) return "";
|
|
1621
|
+
const others = await uncommitted(state.top, state.files.map((file) => file.path).filter((path) => !ours.includes(path))).catch(() => []);
|
|
1622
|
+
const left = others.length > 0 ? ` Left uncommitted, not this task's agents' work: ${others.slice(0, 10).join(", ")}${others.length > 10 ? ` and ${others.length - 10} more` : ""}.` : "";
|
|
1623
|
+
return ` Committed ${result.files.length} file${result.files.length === 1 ? "" : "s"} as ${result.commit.slice(0, 7)}: "${message.split("\n")[0]}".${left}`;
|
|
1585
1624
|
}
|
|
1586
1625
|
|
|
1587
1626
|
/** Completion always offers delivery to the user, including in auto mode. */
|