@a-t-h-i/bot-lobby 0.3.0 → 0.5.0
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 +266 -35
- package/package.json +1 -1
- package/prompts/backend.md +46 -1
- package/prompts/designer.md +94 -15
- package/prompts/master.md +70 -1
- package/prompts/panel.md +39 -0
- package/prompts/planner.md +64 -0
- package/prompts/qa.md +35 -2
- package/prompts/quickfix.md +41 -0
- package/prompts/researcher.md +6 -0
- package/prompts/reviewer.md +16 -0
- package/prompts/scout.md +11 -2
- package/prompts/worker.md +35 -2
- package/src/desk/client-extension.ts +101 -0
- package/src/desk/desk.ts +249 -0
- package/src/desk/ipc.ts +178 -0
- package/src/desk/session.ts +214 -0
- package/src/execution/agent-runner.ts +165 -14
- package/src/execution/pi-runner.ts +376 -65
- package/src/index.ts +7 -0
- package/src/lobby/feed.ts +253 -0
- package/src/lobby/issues.ts +227 -0
- package/src/lobby/layout.ts +174 -0
- package/src/lobby/planner.ts +474 -0
- package/src/lobby/quickfix.ts +227 -0
- package/src/lobby/runtime.ts +440 -0
- package/src/lobby/tabs/home.ts +164 -0
- package/src/lobby/tabs/issues.ts +72 -0
- package/src/lobby/tabs/metrics.ts +162 -0
- package/src/lobby/tabs/plan.ts +160 -0
- package/src/lobby/tabs/quickfix.ts +101 -0
- package/src/lobby/tabs/tasks.ts +209 -0
- package/src/lobby/view.ts +855 -0
- package/src/master/master.ts +21 -17
- package/src/master/research.ts +8 -7
- package/src/pi/activity.ts +165 -0
- package/src/pi/commands.ts +46 -59
- package/src/pi/events.ts +5 -2
- package/src/pi/expressions.ts +43 -12
- package/src/pi/kaomoji.ts +227 -0
- package/src/pi/mascot-art.ts +5 -15
- package/src/pi/model-support.ts +135 -0
- package/src/pi/run-summary.ts +172 -0
- package/src/pi/settings-ui.ts +162 -60
- package/src/pi/start-task.ts +63 -0
- package/src/pi/tools.ts +51 -8
- package/src/pi/ui.ts +151 -49
- package/src/pi/zen-large.ts +41 -4
- package/src/pi/zen-metrics.ts +47 -7
- package/src/pi/zen.ts +29 -8
- package/src/roles/reviewer.ts +24 -4
- package/src/roles/worker.ts +7 -1
- package/src/schemas/configuration.ts +177 -18
- package/src/schemas/findings.ts +26 -0
- package/src/schemas/task.ts +27 -1
- package/src/state/backlog.ts +106 -0
- package/src/state/comments.ts +136 -0
- package/src/state/metrics.ts +305 -0
- package/src/state/project.ts +9 -0
- package/src/text.ts +9 -0
- package/src/workflow/workflow.ts +161 -12
package/src/desk/desk.ts
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The file desk: checkout and handover of repository files between workers
|
|
3
|
+
* that run in parallel, the way people share a physical document.
|
|
4
|
+
*
|
|
5
|
+
* A worker claims a file with a one-line intent before editing it. A free
|
|
6
|
+
* file is granted at once; a held file queues the claimant behind the holder,
|
|
7
|
+
* and the holder is told who is waiting and what they intend. When the holder
|
|
8
|
+
* hands the file over (with a note written for the next worker's intent), or
|
|
9
|
+
* finishes, the file passes to the head of the queue together with that note.
|
|
10
|
+
* Reads never need a claim. Everything lives in the master process, so a
|
|
11
|
+
* crashed worker can never leave a stale lock behind.
|
|
12
|
+
*/
|
|
13
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
14
|
+
|
|
15
|
+
export type WorkerId = string;
|
|
16
|
+
|
|
17
|
+
export interface Claim {
|
|
18
|
+
worker: WorkerId;
|
|
19
|
+
intent: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** One file's checkout state as a worker sees it. */
|
|
23
|
+
export interface FileView {
|
|
24
|
+
path: string;
|
|
25
|
+
holder: Claim;
|
|
26
|
+
queue: Claim[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface Handover {
|
|
30
|
+
path: string;
|
|
31
|
+
from: WorkerId;
|
|
32
|
+
to: WorkerId;
|
|
33
|
+
note: string;
|
|
34
|
+
auto: boolean;
|
|
35
|
+
at: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type ClaimResult =
|
|
39
|
+
| { status: "granted"; path: string; queue: Claim[] }
|
|
40
|
+
| { status: "held"; path: string; queue: Claim[] }
|
|
41
|
+
| { status: "queued"; path: string; holder: Claim; position: number };
|
|
42
|
+
|
|
43
|
+
export type HandoverResult =
|
|
44
|
+
| { ok: true; path: string; to?: Claim; queue: Claim[] }
|
|
45
|
+
| { ok: false; path: string; error: string };
|
|
46
|
+
|
|
47
|
+
/** What the desk tells workers; the session turns these into steering messages. */
|
|
48
|
+
export type DeskEvent =
|
|
49
|
+
/** `to` holds `path` and its queue changed (someone joined or left). */
|
|
50
|
+
| { type: "queue"; to: WorkerId; path: string; queue: Claim[] }
|
|
51
|
+
/** `to` now holds `path`, handed over by `from` with `note`; `queue` is who waits behind it. */
|
|
52
|
+
| { type: "granted"; to: WorkerId; path: string; from: WorkerId; note: string; intent: string; queue: Claim[] }
|
|
53
|
+
/** `worker` joined the queue for `path` behind `holder`. */
|
|
54
|
+
| { type: "queued"; worker: WorkerId; path: string; holder: WorkerId; position: number };
|
|
55
|
+
|
|
56
|
+
interface Entry {
|
|
57
|
+
holder: Claim;
|
|
58
|
+
queue: Claim[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface Waiter {
|
|
62
|
+
resolve: (grants: DeskEvent[]) => void;
|
|
63
|
+
timer: ReturnType<typeof setTimeout>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const MAX_INTENT = 160;
|
|
67
|
+
const MAX_NOTE = 1200;
|
|
68
|
+
|
|
69
|
+
function clean(text: string, max: number): string {
|
|
70
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
71
|
+
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export class FileDesk {
|
|
75
|
+
private readonly files = new Map<string, Entry>();
|
|
76
|
+
private readonly waiters = new Map<WorkerId, Waiter>();
|
|
77
|
+
private readonly pendingGrants = new Map<WorkerId, DeskEvent[]>();
|
|
78
|
+
private readonly log: Handover[] = [];
|
|
79
|
+
private readonly cwd: string;
|
|
80
|
+
private readonly onEvent: (event: DeskEvent) => void;
|
|
81
|
+
private readonly now: () => number;
|
|
82
|
+
|
|
83
|
+
constructor(cwd: string, onEvent: (event: DeskEvent) => void = () => {}, now: () => number = Date.now) {
|
|
84
|
+
this.cwd = resolve(cwd);
|
|
85
|
+
this.onEvent = onEvent;
|
|
86
|
+
this.now = now;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A stable key and display path for a file, relative to the repository root. */
|
|
90
|
+
normalize(path: string): string {
|
|
91
|
+
const absolute = isAbsolute(path) ? resolve(path) : resolve(this.cwd, path);
|
|
92
|
+
const rel = relative(this.cwd, absolute);
|
|
93
|
+
const display = rel && !rel.startsWith("..") && !isAbsolute(rel) ? rel : absolute;
|
|
94
|
+
return display.replace(/\\/g, "/");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Claim `path` for `worker` with a short intent; grants a free file, queues behind a holder. */
|
|
98
|
+
claim(worker: WorkerId, path: string, intent: string): ClaimResult {
|
|
99
|
+
const key = this.normalize(path);
|
|
100
|
+
const claim = { worker, intent: clean(intent || "edit", MAX_INTENT) };
|
|
101
|
+
const entry = this.files.get(key);
|
|
102
|
+
if (!entry) {
|
|
103
|
+
this.files.set(key, { holder: claim, queue: [] });
|
|
104
|
+
return { status: "granted", path: key, queue: [] };
|
|
105
|
+
}
|
|
106
|
+
if (entry.holder.worker === worker) {
|
|
107
|
+
entry.holder = claim;
|
|
108
|
+
return { status: "held", path: key, queue: [...entry.queue] };
|
|
109
|
+
}
|
|
110
|
+
const existing = entry.queue.findIndex((queued) => queued.worker === worker);
|
|
111
|
+
if (existing >= 0) entry.queue[existing] = claim;
|
|
112
|
+
else entry.queue.push(claim);
|
|
113
|
+
const position = entry.queue.findIndex((queued) => queued.worker === worker) + 1;
|
|
114
|
+
if (existing < 0) {
|
|
115
|
+
this.onEvent({ type: "queued", worker, path: key, holder: entry.holder.worker, position });
|
|
116
|
+
this.onEvent({ type: "queue", to: entry.holder.worker, path: key, queue: [...entry.queue] });
|
|
117
|
+
}
|
|
118
|
+
return { status: "queued", path: key, holder: entry.holder, position };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** True when `worker` currently holds `path`. */
|
|
122
|
+
holds(worker: WorkerId, path: string): boolean {
|
|
123
|
+
return this.files.get(this.normalize(path))?.holder.worker === worker;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Who holds `path`, if anyone. */
|
|
127
|
+
holderOf(path: string): Claim | undefined {
|
|
128
|
+
return this.files.get(this.normalize(path))?.holder;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Hand `path` to whoever is next in its queue, with a note for them; free it when nobody waits. */
|
|
132
|
+
handover(worker: WorkerId, path: string, note: string): HandoverResult {
|
|
133
|
+
const key = this.normalize(path);
|
|
134
|
+
const entry = this.files.get(key);
|
|
135
|
+
if (!entry || entry.holder.worker !== worker) {
|
|
136
|
+
return { ok: false, path: key, error: `you do not hold ${key}${entry ? ` (${entry.holder.worker} does)` : ""}` };
|
|
137
|
+
}
|
|
138
|
+
const next = this.pass(key, entry, worker, clean(note || "no note", MAX_NOTE), false);
|
|
139
|
+
return { ok: true, path: key, ...(next ? { to: next } : {}), queue: [...(this.files.get(key)?.queue ?? [])] };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Release everything `worker` holds (handing each file to its next worker
|
|
144
|
+
* with `note(path, next)`) and drop it from every queue. Used when a worker
|
|
145
|
+
* finishes, crashes or retries.
|
|
146
|
+
*/
|
|
147
|
+
release(worker: WorkerId, note: (path: string, next: Claim) => string): Handover[] {
|
|
148
|
+
const passed: Handover[] = [];
|
|
149
|
+
for (const [key, entry] of [...this.files]) {
|
|
150
|
+
const before = entry.queue.length;
|
|
151
|
+
entry.queue = entry.queue.filter((queued) => queued.worker !== worker);
|
|
152
|
+
if (entry.holder.worker === worker) {
|
|
153
|
+
const head = entry.queue[0];
|
|
154
|
+
const next = this.pass(key, entry, worker, head ? clean(note(key, head), MAX_NOTE) : "", true);
|
|
155
|
+
if (next) passed.push(this.log.at(-1)!);
|
|
156
|
+
} else if (entry.queue.length !== before) {
|
|
157
|
+
this.onEvent({ type: "queue", to: entry.holder.worker, path: key, queue: [...entry.queue] });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const waiter = this.waiters.get(worker);
|
|
161
|
+
if (waiter) {
|
|
162
|
+
clearTimeout(waiter.timer);
|
|
163
|
+
this.waiters.delete(worker);
|
|
164
|
+
waiter.resolve([]);
|
|
165
|
+
}
|
|
166
|
+
this.pendingGrants.delete(worker);
|
|
167
|
+
return passed;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private pass(key: string, entry: Entry, from: WorkerId, note: string, auto: boolean): Claim | undefined {
|
|
171
|
+
const next = entry.queue.shift();
|
|
172
|
+
if (!next) {
|
|
173
|
+
this.files.delete(key);
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
|
176
|
+
entry.holder = next;
|
|
177
|
+
this.log.push({ path: key, from, to: next.worker, note, auto, at: this.now() });
|
|
178
|
+
const event: DeskEvent = { type: "granted", to: next.worker, path: key, from, note, intent: next.intent, queue: [...entry.queue] };
|
|
179
|
+
this.onEvent(event);
|
|
180
|
+
this.deliver(next.worker, event);
|
|
181
|
+
return next;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Wake a worker blocked in `wait` with its grant, or keep it for its next wait. */
|
|
185
|
+
private deliver(worker: WorkerId, event: DeskEvent): void {
|
|
186
|
+
const waiter = this.waiters.get(worker);
|
|
187
|
+
if (waiter) {
|
|
188
|
+
clearTimeout(waiter.timer);
|
|
189
|
+
this.waiters.delete(worker);
|
|
190
|
+
waiter.resolve([event]);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
this.pendingGrants.set(worker, [...(this.pendingGrants.get(worker) ?? []), event]);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Files `worker` holds that someone is waiting for; it must hand these over before it may wait. */
|
|
197
|
+
owed(worker: WorkerId): FileView[] {
|
|
198
|
+
return this.heldBy(worker).filter((file) => file.queue.length > 0);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
heldBy(worker: WorkerId): FileView[] {
|
|
202
|
+
return [...this.files].filter(([, entry]) => entry.holder.worker === worker).map(([path, entry]) => ({ path, holder: entry.holder, queue: [...entry.queue] }));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Files `worker` is queued for, with its position. */
|
|
206
|
+
waitingFor(worker: WorkerId): Array<{ path: string; holder: Claim; position: number }> {
|
|
207
|
+
return [...this.files].flatMap(([path, entry]) => {
|
|
208
|
+
const index = entry.queue.findIndex((queued) => queued.worker === worker);
|
|
209
|
+
return index >= 0 ? [{ path, holder: entry.holder, position: index + 1 }] : [];
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Wait up to `timeoutMs` for a file to be handed to `worker`. Refuses while
|
|
215
|
+
* the worker holds files others are waiting for, which breaks deadlock
|
|
216
|
+
* cycles: whoever waits first hands over first.
|
|
217
|
+
*/
|
|
218
|
+
wait(worker: WorkerId, timeoutMs: number): Promise<{ refused?: FileView[]; grants: DeskEvent[] }> {
|
|
219
|
+
const owed = this.owed(worker);
|
|
220
|
+
if (owed.length > 0) return Promise.resolve({ refused: owed, grants: [] });
|
|
221
|
+
const pending = this.pendingGrants.get(worker);
|
|
222
|
+
if (pending?.length) {
|
|
223
|
+
this.pendingGrants.delete(worker);
|
|
224
|
+
return Promise.resolve({ grants: pending });
|
|
225
|
+
}
|
|
226
|
+
if (this.waitingFor(worker).length === 0) return Promise.resolve({ grants: [] });
|
|
227
|
+
return new Promise((done) => {
|
|
228
|
+
const timer = setTimeout(() => {
|
|
229
|
+
this.waiters.delete(worker);
|
|
230
|
+
done({ grants: [] });
|
|
231
|
+
}, Math.max(0, timeoutMs));
|
|
232
|
+
this.waiters.set(worker, { resolve: (grants) => done({ grants }), timer });
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Every handover so far, oldest first. */
|
|
237
|
+
handovers(): Handover[] {
|
|
238
|
+
return [...this.log];
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Wake every waiter; called when the batch ends. */
|
|
242
|
+
close(): void {
|
|
243
|
+
for (const [worker, waiter] of this.waiters) {
|
|
244
|
+
clearTimeout(waiter.timer);
|
|
245
|
+
waiter.resolve([]);
|
|
246
|
+
this.waiters.delete(worker);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
package/src/desk/ipc.ts
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transport between the file desk (master process) and the workers' pi
|
|
3
|
+
* processes: newline-delimited JSON requests and responses over a private
|
|
4
|
+
* Unix socket (a named pipe on Windows). One server per parallel batch.
|
|
5
|
+
*/
|
|
6
|
+
import { randomBytes } from "node:crypto";
|
|
7
|
+
import { chmodSync, rmSync } from "node:fs";
|
|
8
|
+
import { createConnection, createServer, type Socket } from "node:net";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
export type DeskOp = "hello" | "check" | "claim" | "handover" | "mine" | "wait";
|
|
13
|
+
|
|
14
|
+
export interface DeskRequest {
|
|
15
|
+
id: number;
|
|
16
|
+
worker: string;
|
|
17
|
+
op: DeskOp;
|
|
18
|
+
path?: string;
|
|
19
|
+
intent?: string;
|
|
20
|
+
note?: string;
|
|
21
|
+
timeoutMs?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface DeskResponse {
|
|
25
|
+
id: number;
|
|
26
|
+
ok: boolean;
|
|
27
|
+
/** Human-readable answer for the worker's tool result or block reason. */
|
|
28
|
+
text: string;
|
|
29
|
+
/** `check` only: whether the worker may edit the file. */
|
|
30
|
+
allowed?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type DeskHandler = (request: DeskRequest) => Promise<Omit<DeskResponse, "id">>;
|
|
34
|
+
|
|
35
|
+
export interface DeskServer {
|
|
36
|
+
address: string;
|
|
37
|
+
close(): Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function socketAddress(): string {
|
|
41
|
+
const name = `bl-desk-${process.pid}-${randomBytes(4).toString("hex")}`;
|
|
42
|
+
return process.platform === "win32" ? `\\\\.\\pipe\\${name}` : join(tmpdir(), `${name}.sock`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Split a byte stream into complete lines, keeping any partial tail. */
|
|
46
|
+
function lineReader(onLine: (line: string) => void): (chunk: string) => void {
|
|
47
|
+
let buffer = "";
|
|
48
|
+
return (chunk) => {
|
|
49
|
+
buffer += chunk;
|
|
50
|
+
let at = buffer.indexOf("\n");
|
|
51
|
+
while (at >= 0) {
|
|
52
|
+
const line = buffer.slice(0, at).trim();
|
|
53
|
+
buffer = buffer.slice(at + 1);
|
|
54
|
+
if (line) onLine(line);
|
|
55
|
+
at = buffer.indexOf("\n");
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function serve(socket: Socket, handler: DeskHandler): void {
|
|
61
|
+
socket.setEncoding("utf8");
|
|
62
|
+
socket.on("error", () => socket.destroy());
|
|
63
|
+
socket.on(
|
|
64
|
+
"data",
|
|
65
|
+
lineReader((line) => {
|
|
66
|
+
let request: DeskRequest;
|
|
67
|
+
try {
|
|
68
|
+
request = JSON.parse(line) as DeskRequest;
|
|
69
|
+
} catch {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
handler(request)
|
|
73
|
+
.catch((error: Error) => ({ ok: false, text: `desk error: ${error.message}` }))
|
|
74
|
+
.then((response) => {
|
|
75
|
+
if (!socket.destroyed) socket.write(`${JSON.stringify({ ...response, id: request.id })}\n`);
|
|
76
|
+
});
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Start a desk server on a fresh private socket. */
|
|
82
|
+
export async function startDeskServer(handler: DeskHandler): Promise<DeskServer> {
|
|
83
|
+
const address = socketAddress();
|
|
84
|
+
const sockets = new Set<Socket>();
|
|
85
|
+
const server = createServer((socket) => {
|
|
86
|
+
sockets.add(socket);
|
|
87
|
+
socket.on("close", () => sockets.delete(socket));
|
|
88
|
+
serve(socket, handler);
|
|
89
|
+
});
|
|
90
|
+
await new Promise<void>((done, fail) => {
|
|
91
|
+
server.once("error", fail);
|
|
92
|
+
server.listen(address, () => done());
|
|
93
|
+
});
|
|
94
|
+
if (process.platform !== "win32") {
|
|
95
|
+
try {
|
|
96
|
+
chmodSync(address, 0o600);
|
|
97
|
+
} catch {
|
|
98
|
+
// Best effort: the socket already lives in the private temp dir.
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
address,
|
|
103
|
+
close: () =>
|
|
104
|
+
new Promise((done) => {
|
|
105
|
+
for (const socket of sockets) socket.destroy();
|
|
106
|
+
server.close(() => {
|
|
107
|
+
if (process.platform !== "win32") rmSync(address, { force: true });
|
|
108
|
+
done();
|
|
109
|
+
});
|
|
110
|
+
}),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface DeskClient {
|
|
115
|
+
request(request: Omit<DeskRequest, "id" | "worker">, timeoutMs?: number): Promise<DeskResponse>;
|
|
116
|
+
close(): void;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** A persistent connection from one worker to the desk; requests fail (never hang) when it is gone. */
|
|
120
|
+
export function createDeskClient(address: string, worker: string): DeskClient {
|
|
121
|
+
let socket: Socket | undefined;
|
|
122
|
+
let nextId = 1;
|
|
123
|
+
const pending = new Map<number, { done: (response: DeskResponse) => void; timer: ReturnType<typeof setTimeout> }>();
|
|
124
|
+
const failAll = (text: string) => {
|
|
125
|
+
for (const [id, entry] of pending) {
|
|
126
|
+
clearTimeout(entry.timer);
|
|
127
|
+
entry.done({ id, ok: false, text });
|
|
128
|
+
}
|
|
129
|
+
pending.clear();
|
|
130
|
+
};
|
|
131
|
+
const connect = (): Socket => {
|
|
132
|
+
if (socket && !socket.destroyed) return socket;
|
|
133
|
+
const next = createConnection(address);
|
|
134
|
+
next.setEncoding("utf8");
|
|
135
|
+
next.on(
|
|
136
|
+
"data",
|
|
137
|
+
lineReader((line) => {
|
|
138
|
+
let response: DeskResponse;
|
|
139
|
+
try {
|
|
140
|
+
response = JSON.parse(line) as DeskResponse;
|
|
141
|
+
} catch {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const entry = pending.get(response.id);
|
|
145
|
+
if (!entry) return;
|
|
146
|
+
pending.delete(response.id);
|
|
147
|
+
clearTimeout(entry.timer);
|
|
148
|
+
entry.done(response);
|
|
149
|
+
}),
|
|
150
|
+
);
|
|
151
|
+
next.on("error", () => failAll("the file desk is unavailable"));
|
|
152
|
+
next.on("close", () => failAll("the file desk closed"));
|
|
153
|
+
next.unref();
|
|
154
|
+
socket = next;
|
|
155
|
+
return next;
|
|
156
|
+
};
|
|
157
|
+
return {
|
|
158
|
+
request(request, timeoutMs = 10_000) {
|
|
159
|
+
const id = nextId++;
|
|
160
|
+
return new Promise((done) => {
|
|
161
|
+
const timer = setTimeout(() => {
|
|
162
|
+
pending.delete(id);
|
|
163
|
+
done({ id, ok: false, text: "the file desk did not answer" });
|
|
164
|
+
}, timeoutMs);
|
|
165
|
+
pending.set(id, { done, timer });
|
|
166
|
+
try {
|
|
167
|
+
connect().write(`${JSON.stringify({ ...request, id, worker })}\n`);
|
|
168
|
+
} catch {
|
|
169
|
+
failAll("the file desk is unavailable");
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
},
|
|
173
|
+
close() {
|
|
174
|
+
socket?.destroy();
|
|
175
|
+
socket = undefined;
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One parallel batch's file desk as the master sees it: the checkout table,
|
|
3
|
+
* the socket the workers talk to, and the messages that keep every worker
|
|
4
|
+
* informed. Queue changes and handovers reach the right worker as steering
|
|
5
|
+
* messages over its RPC stdin, and the zen panel sees who is waiting.
|
|
6
|
+
*/
|
|
7
|
+
import { basename } from "node:path";
|
|
8
|
+
import type { AgentHandle } from "../execution/agent-runner.ts";
|
|
9
|
+
import { FileDesk, type Claim, type DeskEvent, type FileView, type Handover, type WorkerId } from "./desk.ts";
|
|
10
|
+
import { startDeskServer, type DeskRequest, type DeskResponse, type DeskServer } from "./ipc.ts";
|
|
11
|
+
|
|
12
|
+
/** Tools the desk adds to every parallel worker's allowlist. */
|
|
13
|
+
export const DESK_TOOLS: readonly string[] = ["claim_file", "handover_file", "my_files", "wait_for_files"];
|
|
14
|
+
|
|
15
|
+
/** Env names the desk client extension reads inside a worker process. */
|
|
16
|
+
export const DESK_ENV = { address: "BOT_LOBBY_DESK", worker: "BOT_LOBBY_DESK_ID" } as const;
|
|
17
|
+
|
|
18
|
+
/** Longest a single `wait_for_files` call may block. */
|
|
19
|
+
export const MAX_WAIT_MS = 120_000;
|
|
20
|
+
|
|
21
|
+
const EDIT_TOOLS = new Set(["edit", "write"]);
|
|
22
|
+
|
|
23
|
+
export function isEditTool(name: string): boolean {
|
|
24
|
+
return EDIT_TOOLS.has(name);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function label(worker: WorkerId): string {
|
|
28
|
+
const names: Record<string, string> = { backend: "DEV", designer: "DESIGN", qa: "QA" };
|
|
29
|
+
return names[worker] ?? worker.toUpperCase();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function queueLines(queue: readonly Claim[]): string {
|
|
33
|
+
return queue.map((claim, index) => `${index + 1}) ${label(claim.worker)} — ${claim.intent}`).join("; ");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Steering text for a holder whose queue changed. */
|
|
37
|
+
export function queueMessage(path: string, queue: readonly Claim[]): string {
|
|
38
|
+
if (queue.length === 0) return `bot-lobby desk: nobody is waiting for \`${path}\` any more.`;
|
|
39
|
+
const next = queue[0]!;
|
|
40
|
+
return [
|
|
41
|
+
`bot-lobby desk: queue for \`${path}\` (you hold it): ${queueLines(queue)}.`,
|
|
42
|
+
`As soon as you are done with it, call handover_file with a short note for ${label(next.worker)}, who wants to: ${next.intent}.`,
|
|
43
|
+
].join(" ");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Steering text for a worker who just received a file. */
|
|
47
|
+
export function grantMessage(event: Extract<DeskEvent, { type: "granted" }>): string {
|
|
48
|
+
const behind = event.queue.length > 0 ? ` Queued behind you: ${queueLines(event.queue)}; hand it over when you are done.` : "";
|
|
49
|
+
return `bot-lobby desk: \`${event.path}\` is yours now (for: ${event.intent}). ${label(event.from)}'s handover note: ${event.note || "none"}. Re-read the file before editing it.${behind}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function describeHeld(files: readonly FileView[]): string {
|
|
53
|
+
if (files.length === 0) return "You hold no files.";
|
|
54
|
+
return files
|
|
55
|
+
.map((file) => `You hold \`${file.path}\` (${file.holder.intent})${file.queue.length > 0 ? `; waiting: ${queueLines(file.queue)}` : "; nobody waiting"}.`)
|
|
56
|
+
.join("\n");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface DeskSessionOptions {
|
|
60
|
+
cwd: string;
|
|
61
|
+
/** A worker that finished without handing a file over leaves this note for the next worker. */
|
|
62
|
+
onHandover?: (handover: Handover) => void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Master-side desk for one batch of parallel workers. */
|
|
66
|
+
export class DeskSession {
|
|
67
|
+
readonly desk: FileDesk;
|
|
68
|
+
private server?: DeskServer;
|
|
69
|
+
private readonly handles = new Map<WorkerId, AgentHandle>();
|
|
70
|
+
private readonly greeted = new Set<WorkerId>();
|
|
71
|
+
private readonly options: DeskSessionOptions;
|
|
72
|
+
|
|
73
|
+
constructor(options: DeskSessionOptions) {
|
|
74
|
+
this.options = options;
|
|
75
|
+
this.desk = new FileDesk(options.cwd, (event) => this.onEvent(event));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async open(): Promise<void> {
|
|
79
|
+
this.server = await startDeskServer((request) => this.handle(request));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Environment that points one worker's pi process at this desk. */
|
|
83
|
+
env(worker: WorkerId): Record<string, string> {
|
|
84
|
+
if (!this.server) throw new Error("desk is not open");
|
|
85
|
+
return { [DESK_ENV.address]: this.server.address, [DESK_ENV.worker]: worker };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Remember the running attempt's handle so the desk can steer it. */
|
|
89
|
+
attach(worker: WorkerId, handle: AgentHandle): void {
|
|
90
|
+
this.handles.set(worker, handle);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** True once the worker's desk extension has checked in. */
|
|
94
|
+
greetedBy(worker: WorkerId): boolean {
|
|
95
|
+
return this.greeted.has(worker);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** End of one attempt: hand everything it held to whoever is next, with `note` for them. */
|
|
99
|
+
release(worker: WorkerId, note: (path: string, next: Claim) => string): void {
|
|
100
|
+
this.handles.get(worker)?.annotate({ waitingFor: undefined });
|
|
101
|
+
this.handles.delete(worker);
|
|
102
|
+
for (const handover of this.desk.release(worker, note)) this.options.onHandover?.(handover);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
handovers(): Handover[] {
|
|
106
|
+
return this.desk.handovers();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async close(): Promise<void> {
|
|
110
|
+
this.desk.close();
|
|
111
|
+
await this.server?.close();
|
|
112
|
+
this.server = undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private steer(worker: WorkerId, text: string): void {
|
|
116
|
+
this.handles.get(worker)?.steer(text);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private onEvent(event: DeskEvent): void {
|
|
120
|
+
if (event.type === "queue") {
|
|
121
|
+
this.steer(event.to, queueMessage(event.path, event.queue));
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (event.type === "queued") {
|
|
125
|
+
this.handles.get(event.worker)?.annotate({ note: `queued for ${basename(event.path)} behind ${label(event.holder)}`, noteKind: "info" });
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
this.steer(event.to, grantMessage(event));
|
|
129
|
+
this.handles.get(event.to)?.annotate({ note: `got ${basename(event.path)} from ${label(event.from)}`, noteKind: "info", waitingFor: undefined });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private async handle(request: DeskRequest): Promise<Omit<DeskResponse, "id">> {
|
|
133
|
+
const worker = request.worker;
|
|
134
|
+
switch (request.op) {
|
|
135
|
+
case "hello":
|
|
136
|
+
this.greeted.add(worker);
|
|
137
|
+
return { ok: true, text: "desk connected" };
|
|
138
|
+
case "check":
|
|
139
|
+
return this.check(worker, request.path ?? "");
|
|
140
|
+
case "claim":
|
|
141
|
+
return this.claim(worker, request.path ?? "", request.intent ?? "");
|
|
142
|
+
case "handover":
|
|
143
|
+
return this.handover(worker, request.path ?? "", request.note ?? "");
|
|
144
|
+
case "mine":
|
|
145
|
+
return { ok: true, text: this.mine(worker) };
|
|
146
|
+
case "wait":
|
|
147
|
+
return this.wait(worker, Math.min(MAX_WAIT_MS, Math.max(1000, request.timeoutMs ?? MAX_WAIT_MS)));
|
|
148
|
+
default:
|
|
149
|
+
return { ok: false, text: `unknown desk operation ${String(request.op)}` };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private check(worker: WorkerId, path: string): Omit<DeskResponse, "id"> {
|
|
154
|
+
if (this.desk.holds(worker, path)) return { ok: true, allowed: true, text: "" };
|
|
155
|
+
const key = this.desk.normalize(path);
|
|
156
|
+
const holder = this.desk.holderOf(path);
|
|
157
|
+
if (!holder) {
|
|
158
|
+
return { ok: true, allowed: false, text: `Claim \`${key}\` first: call claim_file with its path and a one-line intent (what you will change), then retry this edit.` };
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
ok: true,
|
|
162
|
+
allowed: false,
|
|
163
|
+
text: `\`${key}\` is checked out by ${label(holder.worker)} (${holder.intent}). Call claim_file to join its queue, keep working on your other files, and edit it once it is handed to you.`,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private claim(worker: WorkerId, path: string, intent: string): Omit<DeskResponse, "id"> {
|
|
168
|
+
if (!path.trim()) return { ok: false, text: "claim_file needs a path" };
|
|
169
|
+
const result = this.desk.claim(worker, path, intent);
|
|
170
|
+
if (result.status === "queued") {
|
|
171
|
+
return {
|
|
172
|
+
ok: true,
|
|
173
|
+
text: `\`${result.path}\` is with ${label(result.holder.worker)} (${result.holder.intent}); you are #${result.position} in line. Keep working on your other files — you will be told when it is handed to you, with their notes.`,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
const behind = result.queue.length > 0 ? ` Waiting behind you: ${queueLines(result.queue)}.` : "";
|
|
177
|
+
return { ok: true, text: `\`${result.path}\` is yours. Edit it, then call handover_file with a note as soon as you are done with it.${behind}` };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private handover(worker: WorkerId, path: string, note: string): Omit<DeskResponse, "id"> {
|
|
181
|
+
const result = this.desk.handover(worker, path, note);
|
|
182
|
+
if (!result.ok) return { ok: false, text: result.error };
|
|
183
|
+
const handover = this.desk.handovers().at(-1);
|
|
184
|
+
if (result.to && handover) this.options.onHandover?.(handover);
|
|
185
|
+
if (!result.to) return { ok: true, text: `\`${result.path}\` released; nobody was waiting for it.` };
|
|
186
|
+
return { ok: true, text: `\`${result.path}\` handed to ${label(result.to.worker)} with your note.` };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private mine(worker: WorkerId): string {
|
|
190
|
+
const waiting = this.desk.waitingFor(worker).map((entry) => `You are #${entry.position} for \`${entry.path}\` (held by ${label(entry.holder.worker)}: ${entry.holder.intent}).`);
|
|
191
|
+
return [describeHeld(this.desk.heldBy(worker)), ...waiting].join("\n");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private async wait(worker: WorkerId, timeoutMs: number): Promise<Omit<DeskResponse, "id">> {
|
|
195
|
+
const queuedFor = this.desk.waitingFor(worker);
|
|
196
|
+
if (queuedFor.length > 0) this.handles.get(worker)?.annotate({ waitingFor: basename(queuedFor[0]!.path) });
|
|
197
|
+
const result = await this.desk.wait(worker, timeoutMs);
|
|
198
|
+
this.handles.get(worker)?.annotate({ waitingFor: undefined });
|
|
199
|
+
if (result.refused) {
|
|
200
|
+
return { ok: false, text: `Hand these over first — others are waiting for them:\n${describeHeld(result.refused)}` };
|
|
201
|
+
}
|
|
202
|
+
const grants = result.grants.filter((event): event is Extract<DeskEvent, { type: "granted" }> => event.type === "granted");
|
|
203
|
+
if (grants.length > 0) return { ok: true, text: grants.map(grantMessage).join("\n") };
|
|
204
|
+
if (queuedFor.length === 0) return { ok: true, text: "You are not waiting for any file." };
|
|
205
|
+
return { ok: true, text: `Still waiting:\n${this.mine(worker)}\nIf nothing else is left, finish your report and list the dependency under Notes; files are handed over automatically when their holder finishes.` };
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** A handover note for `next`, written from a finished worker's report. */
|
|
210
|
+
export function autoNote(from: WorkerId, path: string, next: Claim, changes: ReadonlyArray<{ path: string; change: string }>, completed: string): string {
|
|
211
|
+
const match = changes.find((change) => change.path.replace(/\\/g, "/").endsWith(path) || path.endsWith(change.path.replace(/\\/g, "/")));
|
|
212
|
+
const what = match ? `changed it: ${match.change}` : completed ? `finished; its report says: ${completed.replace(/\s+/g, " ").slice(0, 300)}` : "finished without describing its change";
|
|
213
|
+
return `${label(from)} ${what} (auto-handover on finish; you asked for: ${next.intent})`;
|
|
214
|
+
}
|