@a-t-h-i/bot-lobby 0.6.59 → 0.6.60
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/package.json +7 -7
- package/prompts/global.md +2 -1
- package/prompts/worker.md +2 -9
- package/src/classifier/files.ts +18 -6
- package/src/classifier/instance.ts +2 -2
- package/src/classifier/knowledge.ts +124 -1
- package/src/desk/board-client.ts +79 -12
- package/src/desk/board.ts +63 -52
- package/src/desk/delivery.ts +243 -0
- package/src/desk/ipc.ts +34 -1
- package/src/desk/lock.ts +81 -0
- package/src/desk/session.ts +85 -18
- package/src/execution/agent-runner.ts +10 -2
- package/src/execution/delegation.ts +160 -7
- package/src/execution/output-artifacts.ts +176 -0
- package/src/index.ts +3 -0
- package/src/knowledge/bookmarks.ts +452 -0
- package/src/knowledge/catalog-tools.ts +145 -0
- package/src/knowledge/catalogue.ts +550 -0
- package/src/knowledge/file-notes.ts +16 -6
- package/src/knowledge/repo-map.ts +67 -2
- package/src/knowledge/retrieval.ts +431 -0
- package/src/master/master.ts +132 -21
- package/src/pi/events.ts +31 -2
- package/src/pi/tools.ts +12 -1
- package/src/prompts/compiler.ts +24 -7
- package/src/schemas/configuration.ts +4 -0
- package/src/schemas/task.ts +32 -0
- package/src/state/checkpoint.ts +199 -0
- package/src/state/persistence.ts +138 -3
- package/src/workflow/workflow.ts +62 -11
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable delivery of board posts to each recipient: who was actually shown
|
|
3
|
+
* which post, kept beside the board so a post that lands while its reader is
|
|
4
|
+
* not attached still reaches it at its next safe boundary, and a restart or
|
|
5
|
+
* retry never re-delivers or skips one. `lastSeq` means the text reached the
|
|
6
|
+
* recipient (a steer or a shown read); `handledSeq`/`handled` mean the
|
|
7
|
+
* recipient said so itself — receipt is not proof of incorporation, so only
|
|
8
|
+
* an explicit acknowledgement (`ack_board`) moves them. Posts seen out of
|
|
9
|
+
* order (a fresh blocker pushed ahead of a long backlog) sit in `seen` until
|
|
10
|
+
* the cursor grows past them, and the cursor itself only moves over posts
|
|
11
|
+
* the recipient was actually shown: it never advances past an omitted one.
|
|
12
|
+
* Relevance is routing: the recipient's kind subscription, a post's explicit
|
|
13
|
+
* targets (its assignment id exactly, or the role it answers to), or — for a
|
|
14
|
+
* post naming files — holding one of them; an unrouted post never holds a
|
|
15
|
+
* cursor back. Two same-role workers are two recipients: each has its own
|
|
16
|
+
* cursors, and only what each was shown and acknowledged moves them.
|
|
17
|
+
*/
|
|
18
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
19
|
+
import { dirname } from "node:path";
|
|
20
|
+
import { readBoard, recipientId, roleOf, subscriptionFor, type BoardPost, type PostKind } from "./board.ts";
|
|
21
|
+
import { tempName, withFileLock } from "./lock.ts";
|
|
22
|
+
|
|
23
|
+
/** How many undelivered posts one catch-up or push hands over at most. */
|
|
24
|
+
export const REPLAY_LIMIT = 8;
|
|
25
|
+
|
|
26
|
+
export interface RecipientState {
|
|
27
|
+
/** Highest sequence the recipient was actually shown; nothing above it is assumed seen. */
|
|
28
|
+
lastSeq: number;
|
|
29
|
+
/** Sequences shown out of order above `lastSeq`, waiting for the cursor to grow past them. */
|
|
30
|
+
seen?: number[];
|
|
31
|
+
/** Sequences acknowledged above `handledSeq`, waiting for earlier relevant posts to be acknowledged too. */
|
|
32
|
+
handled?: number[];
|
|
33
|
+
/** Highest sequence the recipient itself acknowledged (contiguous; `ack_board`), when it has. */
|
|
34
|
+
handledSeq?: number;
|
|
35
|
+
at: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface DeliveryFile { version: 1; recipients: Record<string, RecipientState> }
|
|
39
|
+
|
|
40
|
+
/** What routes a post to one recipient: its identity, the kinds it follows, and the files it holds or is queued for. */
|
|
41
|
+
export interface RecipientScope {
|
|
42
|
+
/** The recipient's identity (an assignment id like `backend:9f2c1e`, a domain, a child run id): a post addressing it reaches it alone. */
|
|
43
|
+
recipient: string;
|
|
44
|
+
/** The role (domain) the recipient also answers to: a post addressed to the role reaches every worker of that role. */
|
|
45
|
+
role?: string;
|
|
46
|
+
/** The post kinds the recipient follows. */
|
|
47
|
+
kinds: readonly PostKind[];
|
|
48
|
+
/** Repository files the recipient holds or is queued for: a post naming files reaches intersecting holders. */
|
|
49
|
+
files?: readonly string[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Whether one explicit target of a post names this recipient: its assignment id exactly, the role it answers to, or the same worker under its other spelling (`backend:9f2c1e` vs `9f2c1e`). */
|
|
53
|
+
export function addressesPost(target: string, scope: RecipientScope): boolean {
|
|
54
|
+
const id = recipientId(target);
|
|
55
|
+
const role = scope.role ?? roleOf(scope.recipient);
|
|
56
|
+
if (id === scope.recipient || (!!role && id === recipientId(role))) return true;
|
|
57
|
+
const tail = (name: string): string | undefined =>
|
|
58
|
+
role !== undefined && roleOf(name) === role && name.includes(":") ? name.slice(role.length + 1) : undefined;
|
|
59
|
+
const targetTail = tail(id);
|
|
60
|
+
const recipientTail = tail(scope.recipient);
|
|
61
|
+
return (targetTail !== undefined && targetTail === scope.recipient) || (recipientTail !== undefined && id === recipientTail);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Whether a post routes to one recipient: addressed posts to their targets alone (assignment or role), file-named ones to intersecting subscribed holders (or every subscribed reader when no claims are known), the rest to every subscribed reader. */
|
|
65
|
+
export function routedTo(post: BoardPost, scope: RecipientScope): boolean {
|
|
66
|
+
if (post.to?.length) return post.to.some((target) => addressesPost(target, scope));
|
|
67
|
+
if (post.files?.length) return scope.kinds.includes(post.kind) && (scope.files === undefined || scope.files.some((file) => post.files!.includes(file)));
|
|
68
|
+
return scope.kinds.includes(post.kind);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Where the delivery state for the board at `boardPath` lives. */
|
|
72
|
+
export function deliveryPath(boardPath: string): string {
|
|
73
|
+
return `${boardPath}.delivery.json`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The delivery state, or an empty one when absent or corrupt; never throws. */
|
|
77
|
+
export function readDelivery(path: string): DeliveryFile {
|
|
78
|
+
try {
|
|
79
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial<DeliveryFile>;
|
|
80
|
+
return parsed.version === 1 && typeof parsed.recipients === "object" && parsed.recipients !== null
|
|
81
|
+
? { version: 1, recipients: parsed.recipients }
|
|
82
|
+
: { version: 1, recipients: {} };
|
|
83
|
+
} catch {
|
|
84
|
+
return { version: 1, recipients: {} };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function writeDelivery(path: string, state: DeliveryFile): void {
|
|
89
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
90
|
+
const temp = tempName(path);
|
|
91
|
+
writeFileSync(temp, JSON.stringify(state), "utf8");
|
|
92
|
+
renameSync(temp, path);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function update(path: string, recipient: string, next: RecipientState): RecipientState {
|
|
96
|
+
return withFileLock(path, () => {
|
|
97
|
+
const state = readDelivery(path);
|
|
98
|
+
state.recipients[recipient] = next;
|
|
99
|
+
writeDelivery(path, state);
|
|
100
|
+
return next;
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Advance a recipient's delivered cursor to `seq`, never backwards; `seen` entries it covers are absorbed. Idempotent. */
|
|
105
|
+
export function markDelivered(path: string, recipient: string, seq: number, now = new Date()): RecipientState {
|
|
106
|
+
const prior = readDelivery(path).recipients[recipient];
|
|
107
|
+
if (prior && seq <= prior.lastSeq) return prior;
|
|
108
|
+
const base = prior ?? { lastSeq: 0, at: now.toISOString() };
|
|
109
|
+
return update(path, recipient, { ...base, lastSeq: Math.max(seq, base.lastSeq), seen: (base.seen ?? []).filter((entry) => entry > seq), at: now.toISOString() });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Record an out-of-order delivery without moving the cursor. Idempotent. */
|
|
113
|
+
export function markSeen(path: string, recipient: string, seq: number, now = new Date()): RecipientState {
|
|
114
|
+
const prior = readDelivery(path).recipients[recipient] ?? { lastSeq: 0, at: now.toISOString() };
|
|
115
|
+
if (seq <= prior.lastSeq || (prior.seen ?? []).includes(seq)) return prior;
|
|
116
|
+
return update(path, recipient, { ...prior, seen: [...(prior.seen ?? []), seq].sort((a, b) => a - b), at: now.toISOString() });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Which posts an acknowledgement may cover: everything the recipient's subscription, its role or an explicit address makes its business — and so everything a kinds-only window (a read or a start context) must not be allowed to skip. */
|
|
120
|
+
export function ackRelevant(post: BoardPost, recipient: string, kinds: readonly PostKind[]): boolean {
|
|
121
|
+
const role = roleOf(recipient);
|
|
122
|
+
return kinds.includes(post.kind) || !!post.to?.some((target) => [recipient, ...(role ? [role] : [])].includes(recipientId(target)));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The post one acknowledged event names: a sequence (`3`, `#3`) or a stable post id. */
|
|
126
|
+
function resolveEvent(event: string | number, posts: readonly BoardPost[]): BoardPost | undefined {
|
|
127
|
+
if (typeof event === "number") return posts.find((post) => post.seq === event);
|
|
128
|
+
const trimmed = event.trim().replace(/^#/, "");
|
|
129
|
+
return /^\d+$/.test(trimmed)
|
|
130
|
+
? posts.find((post) => post.seq === Number(trimmed))
|
|
131
|
+
: posts.find((post) => post.id === event.trim());
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Fold acknowledgements into handled state: the contiguous `handledSeq`
|
|
136
|
+
* cursor stops before the first relevant post the recipient has not
|
|
137
|
+
* acknowledged, and out-of-order acknowledgements wait in `handled`.
|
|
138
|
+
*/
|
|
139
|
+
function handledAfter(posts: readonly BoardPost[], prior: RecipientState | undefined, recipient: string, acked: readonly number[], now: Date): RecipientState {
|
|
140
|
+
const kinds = subscriptionFor(recipient);
|
|
141
|
+
const done = new Set([...(prior?.handled ?? []), ...acked]);
|
|
142
|
+
let cursor = prior?.handledSeq ?? 0;
|
|
143
|
+
for (const post of [...posts].sort((a, b) => a.seq - b.seq)) {
|
|
144
|
+
if (!ackRelevant(post, recipient, kinds)) continue;
|
|
145
|
+
if (!done.has(post.seq) && post.seq > cursor) break;
|
|
146
|
+
cursor = Math.max(cursor, post.seq);
|
|
147
|
+
}
|
|
148
|
+
const handled = [...done].filter((seq) => seq > cursor).sort((a, b) => a - b);
|
|
149
|
+
const next: RecipientState = { ...(prior ?? { lastSeq: 0 }), handled, handledSeq: cursor, at: now.toISOString() };
|
|
150
|
+
if (handled.length === 0) delete next.handled;
|
|
151
|
+
return next;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export interface AckResult {
|
|
155
|
+
/** The posts the acknowledgement covers, oldest first (already-acknowledged ones included: it is idempotent). */
|
|
156
|
+
acknowledged: BoardPost[];
|
|
157
|
+
/** Events that name nothing on this board; nothing was recorded for them. */
|
|
158
|
+
unknown: string[];
|
|
159
|
+
/** Events outside the recipient's scope, as `#seq`; nothing was recorded at all. */
|
|
160
|
+
refused: string[];
|
|
161
|
+
state: RecipientState;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Record that `recipient` incorporated the given board events (sequences or
|
|
166
|
+
* stable ids). Idempotent, atomic, and scope-checked: an event that is
|
|
167
|
+
* neither in the recipient's subscription nor addressed to it refuses the
|
|
168
|
+
* whole call, and the handled cursor never skips a relevant post waiting for
|
|
169
|
+
* its own acknowledgement.
|
|
170
|
+
*/
|
|
171
|
+
export function markHandled(boardPath: string, recipient: string, events: readonly (string | number)[], now = new Date()): AckResult {
|
|
172
|
+
const path = deliveryPath(boardPath);
|
|
173
|
+
const board = readBoard(boardPath);
|
|
174
|
+
const kinds = subscriptionFor(recipient);
|
|
175
|
+
const found = events.map((event) => ({ event, post: resolveEvent(event, board) }));
|
|
176
|
+
const unknown = found.filter((entry) => !entry.post).map((entry) => String(entry.event));
|
|
177
|
+
const acknowledged = found.filter((entry) => entry.post).map((entry) => entry.post!);
|
|
178
|
+
const refused = acknowledged.filter((post) => !ackRelevant(post, recipient, kinds)).map((post) => `#${post.seq}`);
|
|
179
|
+
const prior = readDelivery(path).recipients[recipient] ?? { lastSeq: 0, at: now.toISOString() };
|
|
180
|
+
if (refused.length > 0) return { acknowledged: [], unknown, refused, state: prior };
|
|
181
|
+
const state = update(path, recipient, handledAfter(board, prior, recipient, acknowledged.map((post) => post.seq), now));
|
|
182
|
+
return { acknowledged, unknown, refused: [], state };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The posts that route to this recipient's scope and that it has not been shown yet, oldest first. */
|
|
186
|
+
export function undeliveredFor(posts: readonly BoardPost[], state: RecipientState | undefined, scope: RecipientScope): BoardPost[] {
|
|
187
|
+
const seen = new Set(state?.seen ?? []);
|
|
188
|
+
return posts
|
|
189
|
+
.filter((post) => routedTo(post, scope) && post.seq > (state?.lastSeq ?? 0) && !seen.has(post.seq))
|
|
190
|
+
.sort((a, b) => a.seq - b.seq);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** One bounded catch-up: the oldest undelivered posts, and the sequence the cursor may safely move to. */
|
|
194
|
+
export function replayFor(posts: readonly BoardPost[], state: RecipientState | undefined, scope: RecipientScope, limit = REPLAY_LIMIT): { posts: BoardPost[]; through: number; waiting: number } {
|
|
195
|
+
const pending = undeliveredFor(posts, state, scope);
|
|
196
|
+
const batch = pending.slice(0, limit);
|
|
197
|
+
return { posts: batch, through: batch.at(-1)?.seq ?? (state?.lastSeq ?? 0), waiting: pending.length - batch.length };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The newest sequence one shown window proves delivered: the cursor grows
|
|
202
|
+
* over the relevant posts the window showed, oldest first, and stops at the
|
|
203
|
+
* first relevant one the window omitted — nothing unshown is ever claimed.
|
|
204
|
+
*/
|
|
205
|
+
export function provenThrough(posts: readonly BoardPost[], shown: ReadonlySet<number>, relevant: (post: BoardPost) => boolean, start = 0): number {
|
|
206
|
+
let through = start;
|
|
207
|
+
for (const post of [...posts].sort((a, b) => a.seq - b.seq)) {
|
|
208
|
+
if (post.seq <= through || !relevant(post)) continue;
|
|
209
|
+
if (!shown.has(post.seq)) break;
|
|
210
|
+
through = post.seq;
|
|
211
|
+
}
|
|
212
|
+
return through;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* The sequence a start context (the board lines in a prompt) lets a
|
|
217
|
+
* recipient assume seen: the cursor grows over the relevant posts the
|
|
218
|
+
* context showed — its kinds, and anything addressed to it — and stops at
|
|
219
|
+
* the first one it did not. A truncated context proves nothing above its
|
|
220
|
+
* oldest shown post; posts routed only by files wait for the desk that
|
|
221
|
+
* knows the claims.
|
|
222
|
+
*/
|
|
223
|
+
export function deliveredThrough(posts: readonly BoardPost[], recipient: string, limit: number): number {
|
|
224
|
+
const kinds = subscriptionFor(recipient);
|
|
225
|
+
const shown = new Set(posts.filter((post) => kinds.includes(post.kind)).sort((a, b) => a.seq - b.seq).slice(-limit).map((post) => post.seq));
|
|
226
|
+
return provenThrough(posts, shown, (post) => ackRelevant(post, recipient, kinds));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Seed a recipient's cursor from the board lines its start prompt showed,
|
|
231
|
+
* before any push can race the read. Never lowers the cursor; posts the
|
|
232
|
+
* context showed beyond the stop are recorded seen so they are not
|
|
233
|
+
* re-delivered later.
|
|
234
|
+
*/
|
|
235
|
+
export function seedDelivery(boardPath: string, recipient: string, limit = 30, now = new Date()): void {
|
|
236
|
+
const posts = readBoard(boardPath);
|
|
237
|
+
const kinds = subscriptionFor(recipient);
|
|
238
|
+
const window = posts.filter((post) => kinds.includes(post.kind)).sort((a, b) => a.seq - b.seq).slice(-limit);
|
|
239
|
+
const through = provenThrough(posts, new Set(window.map((post) => post.seq)), (post) => ackRelevant(post, recipient, kinds));
|
|
240
|
+
const state = deliveryPath(boardPath);
|
|
241
|
+
if (through > 0) markDelivered(state, recipient, through, now);
|
|
242
|
+
for (const post of window) if (post.seq > through) markSeen(state, recipient, post.seq, now);
|
|
243
|
+
}
|
package/src/desk/ipc.ts
CHANGED
|
@@ -9,7 +9,22 @@ import { createConnection, createServer, type Socket } from "node:net";
|
|
|
9
9
|
import { tmpdir } from "node:os";
|
|
10
10
|
import { join } from "node:path";
|
|
11
11
|
|
|
12
|
-
export type DeskOp =
|
|
12
|
+
export type DeskOp =
|
|
13
|
+
| "hello"
|
|
14
|
+
| "check"
|
|
15
|
+
| "claim"
|
|
16
|
+
| "handover"
|
|
17
|
+
| "mine"
|
|
18
|
+
| "wait"
|
|
19
|
+
| "delegate"
|
|
20
|
+
| "cancel_delegate"
|
|
21
|
+
| "consult"
|
|
22
|
+
| "cancel_consult"
|
|
23
|
+
| "post"
|
|
24
|
+
| "ack"
|
|
25
|
+
| "ask"
|
|
26
|
+
| "answer"
|
|
27
|
+
| "artifact";
|
|
13
28
|
|
|
14
29
|
export interface DeskRequest {
|
|
15
30
|
id: number;
|
|
@@ -25,12 +40,30 @@ export interface DeskRequest {
|
|
|
25
40
|
kind?: string;
|
|
26
41
|
text?: string;
|
|
27
42
|
files?: string[];
|
|
43
|
+
/** post: recipients the post is addressed to (worker ids or domains); it is pushed only to them. */
|
|
44
|
+
targets?: string[];
|
|
45
|
+
/** ack: the board events (sequences or stable ids) the caller says it incorporated. */
|
|
46
|
+
events?: Array<string | number>;
|
|
28
47
|
/** ask: who the question is for (DEV, DESIGN, QA); `path` asks whoever holds that file. */
|
|
29
48
|
to?: string;
|
|
30
49
|
/** answer: the question being answered. */
|
|
31
50
|
questionId?: number;
|
|
32
51
|
/** An expert consult's own id, so the worker can cancel it. */
|
|
33
52
|
consultId?: string;
|
|
53
|
+
/** artifact: the caller's own assignment id; volunteered, never trusted on its own. */
|
|
54
|
+
assignment?: string;
|
|
55
|
+
/** artifact: the O-… reference to open. */
|
|
56
|
+
ref?: string;
|
|
57
|
+
/** artifact: byte offset to continue from. */
|
|
58
|
+
offset?: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The artifact op's caller identity: the socket token already names the caller, and any
|
|
63
|
+
* assignment it volunteers must be the host's own, so unrelated or unregistered ids deny.
|
|
64
|
+
*/
|
|
65
|
+
export function artifactIdentity(request: Pick<DeskRequest, "op" | "assignment">, hostAssignment: string | undefined): boolean {
|
|
66
|
+
return request.op === "artifact" && typeof hostAssignment === "string" && hostAssignment.length > 0 && request.assignment === hostAssignment;
|
|
34
67
|
}
|
|
35
68
|
|
|
36
69
|
export interface DeskResponse {
|
package/src/desk/lock.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A cooperative file lock shared by the board, its delivery state and the
|
|
3
|
+
* scout-note and file caches: a directory made atomically beside the target
|
|
4
|
+
* file, so several processes take turns over one read-modify-write. A lock
|
|
5
|
+
* left behind by a crashed process is broken once stale; a caller that
|
|
6
|
+
* cannot get the lock in time fails closed rather than writing beside the
|
|
7
|
+
* holder and losing changes. Temp names carry the pid and a random suffix,
|
|
8
|
+
* so concurrent writers never collide.
|
|
9
|
+
*/
|
|
10
|
+
import { mkdirSync, rmSync, statSync } from "node:fs";
|
|
11
|
+
import { dirname } from "node:path";
|
|
12
|
+
import { randomUUID } from "node:crypto";
|
|
13
|
+
|
|
14
|
+
/** How long a writer waits for another process's write, and when a lock is taken to be left behind. */
|
|
15
|
+
export const LOCK_WAIT_MS = 2000;
|
|
16
|
+
export const STALE_LOCK_MS = 10_000;
|
|
17
|
+
|
|
18
|
+
export const LOCK_BUSY = "the board is busy; try again";
|
|
19
|
+
|
|
20
|
+
function sleep(ms: number): void {
|
|
21
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A lock's identity: the inode of the directory currently holding it. */
|
|
25
|
+
function lockIdentity(lock: string): string | undefined {
|
|
26
|
+
try {
|
|
27
|
+
return String(statSync(lock).ino);
|
|
28
|
+
} catch {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** False once the lock directory was broken and replaced: another writer may hold it. */
|
|
34
|
+
function stillOurs(lock: string, identity: string | undefined): boolean {
|
|
35
|
+
return identity !== undefined && lockIdentity(lock) === identity;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Run `write` holding the lock for `path` (a directory beside it, made
|
|
40
|
+
* atomically). Throws `LOCK_BUSY` when another live writer holds it past the
|
|
41
|
+
* wait, so no write ever happens beside the holder's. A lock broken as stale
|
|
42
|
+
* while its (slow, not dead) holder kept running is detected before the
|
|
43
|
+
* write: the holder retries from acquisition instead of writing beside the
|
|
44
|
+
* writer that broke it, and never removes a successor's lock on release.
|
|
45
|
+
*/
|
|
46
|
+
export function withFileLock<T>(path: string, write: () => T): T {
|
|
47
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
48
|
+
const lock = `${path}.lock`;
|
|
49
|
+
const until = Date.now() + LOCK_WAIT_MS;
|
|
50
|
+
while (true) {
|
|
51
|
+
try {
|
|
52
|
+
mkdirSync(lock);
|
|
53
|
+
} catch {
|
|
54
|
+
try {
|
|
55
|
+
if (Date.now() - statSync(lock).mtimeMs > STALE_LOCK_MS) rmSync(lock, { recursive: true, force: true });
|
|
56
|
+
} catch {
|
|
57
|
+
// Released meanwhile; the next attempt takes it.
|
|
58
|
+
}
|
|
59
|
+
if (Date.now() > until) throw new Error(LOCK_BUSY);
|
|
60
|
+
sleep(5);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
const identity = lockIdentity(lock);
|
|
64
|
+
if (!stillOurs(lock, identity)) {
|
|
65
|
+
// Broken from under us before the write: take it again rather than write beside the breaker.
|
|
66
|
+
if (Date.now() > until) throw new Error(LOCK_BUSY);
|
|
67
|
+
sleep(5);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
return write();
|
|
72
|
+
} finally {
|
|
73
|
+
if (stillOurs(lock, identity)) rmSync(lock, { recursive: true, force: true });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A temp file name for `path` that no concurrent writer can also pick. */
|
|
79
|
+
export function tempName(path: string): string {
|
|
80
|
+
return `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
81
|
+
}
|
package/src/desk/session.ts
CHANGED
|
@@ -8,7 +8,9 @@ import { basename } from "node:path";
|
|
|
8
8
|
import type { AgentHandle } from "../execution/agent-runner.ts";
|
|
9
9
|
import { FileDesk, type Claim, type DeskEvent, type FileView, type Handover, type WorkerId } from "./desk.ts";
|
|
10
10
|
import { startDeskServer, type DeskRequest, type DeskResponse, type DeskServer } from "./ipc.ts";
|
|
11
|
-
import { appendPost,
|
|
11
|
+
import { appendPost, formatPosts, isPostKind, posterName, PUSHED_KINDS, readBoard, recipientId, roleOf, subscriptionFor, type BoardPost } from "./board.ts";
|
|
12
|
+
import { ackText } from "./board-client.ts";
|
|
13
|
+
import { deliveryPath, markDelivered, markHandled, markSeen, readDelivery, replayFor, routedTo, type RecipientScope, REPLAY_LIMIT, undeliveredFor } from "./delivery.ts";
|
|
12
14
|
|
|
13
15
|
/** Tools the desk adds to every parallel worker's allowlist. */
|
|
14
16
|
export const DESK_TOOLS: readonly string[] = ["claim_file", "handover_file", "my_files", "wait_for_files"];
|
|
@@ -35,15 +37,15 @@ export function isEditTool(name: string): boolean {
|
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
function label(worker: WorkerId): string {
|
|
38
|
-
|
|
39
|
-
return names[worker] ?? worker.toUpperCase();
|
|
40
|
+
return posterName(worker);
|
|
40
41
|
}
|
|
41
42
|
|
|
42
|
-
/** A
|
|
43
|
+
/** A holder named for a handover message: its role when it has one (the note speaks for the role), its full identity otherwise. */
|
|
44
|
+
const roleLabel = (worker: WorkerId): string => label((roleOf(worker) ?? worker) as WorkerId);
|
|
45
|
+
|
|
46
|
+
/** A peer named the way workers know each other (DEV, DESIGN, QA) or by domain; a child run id is itself. */
|
|
43
47
|
export function peerId(name: string): WorkerId {
|
|
44
|
-
|
|
45
|
-
const ids: Record<string, string> = { dev: "backend", backend: "backend", design: "designer", designer: "designer", qa: "qa" };
|
|
46
|
-
return ids[key] ?? name.trim();
|
|
48
|
+
return recipientId(name) as WorkerId;
|
|
47
49
|
}
|
|
48
50
|
|
|
49
51
|
function flat(text: string, max: number): string {
|
|
@@ -77,7 +79,7 @@ export function queueMessage(path: string, queue: readonly Claim[]): string {
|
|
|
77
79
|
/** Steering text for a worker who just received a file. */
|
|
78
80
|
export function grantMessage(event: Extract<DeskEvent, { type: "granted" }>): string {
|
|
79
81
|
const behind = event.queue.length > 0 ? ` Queued behind you: ${queueLines(event.queue)}; hand it over when you are done.` : "";
|
|
80
|
-
return `bot-lobby desk: \`${event.path}\` is yours now (for: ${event.intent}). ${
|
|
82
|
+
return `bot-lobby desk: \`${event.path}\` is yours now (for: ${event.intent}). ${roleLabel(event.from)}'s handover note: ${event.note || "none"}. Re-read the file before editing it.${behind}`;
|
|
81
83
|
}
|
|
82
84
|
|
|
83
85
|
function describeHeld(files: readonly FileView[]): string {
|
|
@@ -131,9 +133,10 @@ export class DeskSession {
|
|
|
131
133
|
return { [DESK_ENV.address]: this.server.address, [DESK_ENV.worker]: worker };
|
|
132
134
|
}
|
|
133
135
|
|
|
134
|
-
/** Remember the running attempt's handle so the desk can steer it. */
|
|
136
|
+
/** Remember the running attempt's handle so the desk can steer it, and catch it up on what it has not been shown. */
|
|
135
137
|
attach(worker: WorkerId, handle: AgentHandle): void {
|
|
136
138
|
this.handles.set(worker, handle);
|
|
139
|
+
this.catchUp(worker);
|
|
137
140
|
}
|
|
138
141
|
|
|
139
142
|
/** False when every worker has a checkout of its own and edits need no claim. */
|
|
@@ -194,7 +197,7 @@ export class DeskSession {
|
|
|
194
197
|
return;
|
|
195
198
|
}
|
|
196
199
|
this.steer(event.to, grantMessage(event));
|
|
197
|
-
this.handles.get(event.to)?.annotate({ note: `got ${basename(event.path)} from ${
|
|
200
|
+
this.handles.get(event.to)?.annotate({ note: `got ${basename(event.path)} from ${roleLabel(event.from)}`, noteKind: "info", waitingFor: undefined });
|
|
198
201
|
}
|
|
199
202
|
|
|
200
203
|
private async handle(request: DeskRequest): Promise<Omit<DeskResponse, "id">> {
|
|
@@ -214,7 +217,9 @@ export class DeskSession {
|
|
|
214
217
|
case "wait":
|
|
215
218
|
return this.wait(worker, Math.min(MAX_WAIT_MS, Math.max(1000, request.timeoutMs ?? MAX_WAIT_MS)));
|
|
216
219
|
case "post":
|
|
217
|
-
return this.post(request.from?.trim() || worker, worker, request.kind ?? "", request.text ?? "", request.files ?? []);
|
|
220
|
+
return this.post(request.from?.trim() || worker, worker, request.kind ?? "", request.text ?? "", request.files ?? [], request.targets ?? []);
|
|
221
|
+
case "ack":
|
|
222
|
+
return this.ack(worker, request.events ?? []);
|
|
218
223
|
case "ask":
|
|
219
224
|
return this.ask(worker, request.to ?? "", request.path ?? "", request.text ?? "", Math.min(MAX_ASK_WAIT_MS, Math.max(5000, request.timeoutMs ?? ASK_WAIT_MS)));
|
|
220
225
|
case "answer":
|
|
@@ -279,24 +284,86 @@ export class DeskSession {
|
|
|
279
284
|
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.` };
|
|
280
285
|
}
|
|
281
286
|
|
|
282
|
-
private post(from: string, worker: WorkerId, kind: string, text: string, files: readonly string[]): Omit<DeskResponse, "id"> {
|
|
287
|
+
private post(from: string, worker: WorkerId, kind: string, text: string, files: readonly string[], targets: readonly string[]): Omit<DeskResponse, "id"> {
|
|
283
288
|
if (!this.options.boardPath) return { ok: false, text: "This task has no board." };
|
|
284
289
|
if (!isPostKind(kind)) return { ok: false, text: `Unknown post kind "${kind}".` };
|
|
290
|
+
const to = [...new Set(targets.map((target) => recipientId(target)).filter(Boolean))];
|
|
285
291
|
let post: BoardPost;
|
|
286
292
|
try {
|
|
287
|
-
post = appendPost(this.options.boardPath, { from, kind, text, files: files.map((file) => this.desk.normalize(file)) });
|
|
293
|
+
post = appendPost(this.options.boardPath, { from, kind, text, files: files.map((file) => this.desk.normalize(file)), ...(to.length > 0 ? { to } : {}) });
|
|
288
294
|
} catch (error) {
|
|
289
295
|
return { ok: false, text: (error as Error).message };
|
|
290
296
|
}
|
|
291
297
|
let told: string[] = [];
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
told = [...this.handles.keys()].filter((peer) => peer !== worker && peer in SUBSCRIPTIONS && subscriptionFor(peer).includes(post.kind));
|
|
295
|
-
for (const peer of told) this.steer(peer, `bot-lobby board: ${formatPost(post)}`);
|
|
296
|
-
}
|
|
298
|
+
// Unaddressed contracts and blockers reach the other workers at once; addressed or file-named posts reach exactly who they route to.
|
|
299
|
+
if (PUSHED_KINDS.has(post.kind) || to.length > 0 || post.files?.length) told = this.deliverPost(post, worker);
|
|
297
300
|
return { ok: true, text: `Posted #${post.seq}.${told.length > 0 ? ` ${told.map(label).join(", ")} ${told.length === 1 ? "was" : "were"} told now.` : ""}` };
|
|
298
301
|
}
|
|
299
302
|
|
|
303
|
+
/** The worker's acknowledgements of board events it incorporated: scope-checked, idempotent, never skipping an earlier relevant one. */
|
|
304
|
+
private ack(worker: WorkerId, events: Array<string | number>): Omit<DeskResponse, "id"> {
|
|
305
|
+
if (!this.options.boardPath) return { ok: false, text: "This task has no board." };
|
|
306
|
+
if (events.length === 0) return { ok: false, text: "ack_board needs the events: `#3`-style numbers or post ids." };
|
|
307
|
+
return { ok: true, text: ackText(markHandled(this.options.boardPath, worker, events)) };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** A consequential post just landed: each peer it routes to hears it now, never only when it next reads. */
|
|
311
|
+
private deliverPost(post: BoardPost, worker: WorkerId): WorkerId[] {
|
|
312
|
+
const told: WorkerId[] = [];
|
|
313
|
+
for (const peer of this.peersFor(post, worker)) {
|
|
314
|
+
if (this.deliverTo(peer, post)) told.push(peer);
|
|
315
|
+
}
|
|
316
|
+
return told;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private peersFor(post: BoardPost, worker: WorkerId): WorkerId[] {
|
|
320
|
+
return [...this.handles.keys()].filter((peer) => peer !== worker && routedTo(post, this.scopeFor(peer)));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** One peer's delivery scope: its subscription, its role, and the files it holds or is queued for (when claims carry meaning). */
|
|
324
|
+
private scopeFor(peer: WorkerId): RecipientScope {
|
|
325
|
+
const kinds = subscriptionFor(peer);
|
|
326
|
+
const role = roleOf(peer);
|
|
327
|
+
const base = { recipient: peer, ...(role && role !== peer ? { role } : {}), kinds };
|
|
328
|
+
if (!this.usesClaims) return base;
|
|
329
|
+
const files = [...this.desk.heldBy(peer).map((file) => file.path), ...this.desk.waitingFor(peer).map((entry) => entry.path)];
|
|
330
|
+
return { ...base, files };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Show one peer the fresh post behind any backlog, oldest first; the cursor moves only over what was sent, so a skipped steer is retried later. */
|
|
334
|
+
private deliverTo(peer: WorkerId, post: BoardPost): boolean {
|
|
335
|
+
const board = this.options.boardPath!;
|
|
336
|
+
const path = deliveryPath(board);
|
|
337
|
+
const queue = undeliveredFor(readBoard(board), readDelivery(path).recipients[peer], this.scopeFor(peer));
|
|
338
|
+
const batch = queue.slice(0, REPLAY_LIMIT);
|
|
339
|
+
const extra = queue.filter((entry) => !batch.includes(entry));
|
|
340
|
+
const shown = extra.includes(post) ? [...batch, post] : batch;
|
|
341
|
+
const waiting = queue.length - shown.length;
|
|
342
|
+
try {
|
|
343
|
+
this.steer(peer, `bot-lobby board: ${formatPosts(shown)}${waiting > 0 ? `\n(+${waiting} older board posts waiting; read_board shows them.)` : ""}`);
|
|
344
|
+
} catch {
|
|
345
|
+
return false;
|
|
346
|
+
}
|
|
347
|
+
markDelivered(path, peer, batch.at(-1)?.seq ?? post.seq);
|
|
348
|
+
// Only the batch and the fresh post were shown; the backlog beyond them stays pending for the next catch-up.
|
|
349
|
+
if (extra.includes(post)) markSeen(path, peer, post.seq);
|
|
350
|
+
return true;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Show a just-attached worker the board posts it was never shown, bounded; a steer that cannot be sent leaves them undelivered. */
|
|
354
|
+
private catchUp(worker: WorkerId): void {
|
|
355
|
+
if (!this.options.boardPath) return;
|
|
356
|
+
const path = deliveryPath(this.options.boardPath);
|
|
357
|
+
const { posts, through, waiting } = replayFor(readBoard(this.options.boardPath), readDelivery(path).recipients[worker], this.scopeFor(worker));
|
|
358
|
+
if (posts.length === 0) return;
|
|
359
|
+
try {
|
|
360
|
+
this.steer(worker, `bot-lobby board: ${formatPosts(posts)}${waiting > 0 ? `\n(+${waiting} older board posts waiting; read_board shows them.)` : ""}`);
|
|
361
|
+
} catch {
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
markDelivered(path, worker, through);
|
|
365
|
+
}
|
|
366
|
+
|
|
300
367
|
private async ask(worker: WorkerId, to: string, path: string, question: string, timeoutMs: number): Promise<Omit<DeskResponse, "id">> {
|
|
301
368
|
if (!this.options.peers) return { ok: false, text: "No peers work alongside you here." };
|
|
302
369
|
const text = flat(question, MAX_QUESTION);
|
|
@@ -6,6 +6,7 @@ import { roleSpec } from "../roles/registry.ts";
|
|
|
6
6
|
import { compilePrompt } from "../prompts/compiler.ts";
|
|
7
7
|
import { EditLog } from "../state/changes.ts";
|
|
8
8
|
import { runPiAgent, spawnPiProcess, type ProcessRunner, type RelayAsk } from "./pi-runner.ts";
|
|
9
|
+
import { KNOWLEDGE_TOOLS, TASK_ID_ENV } from "../knowledge/catalog-tools.ts";
|
|
9
10
|
import { ASK_ENV } from "../ask/relay.ts";
|
|
10
11
|
import type { EffortRouter, RunProfileLike } from "../classifier/effort.ts";
|
|
11
12
|
import { ASK_TOOL } from "../ask/types.ts";
|
|
@@ -16,6 +17,8 @@ export interface AgentContext {
|
|
|
16
17
|
standards?: string;
|
|
17
18
|
knowledge?: string;
|
|
18
19
|
decisions?: string;
|
|
20
|
+
/** Assignment bookmarks: bounded reference metadata pointing into the knowledge catalogue. */
|
|
21
|
+
bookmarks?: string;
|
|
19
22
|
/** The shared scripts that fit the step. */
|
|
20
23
|
scripts?: string;
|
|
21
24
|
/** Per-agent custom instructions from the global config. */
|
|
@@ -148,9 +151,14 @@ function baseRun(request: AgentRequest, runId: string, startedAt: string, attemp
|
|
|
148
151
|
};
|
|
149
152
|
}
|
|
150
153
|
|
|
154
|
+
/** Compiled repository roles may read knowledge; prompt contents never confer access. */
|
|
155
|
+
function hasKnowledgeAccess(request: AgentRequest): boolean {
|
|
156
|
+
return request.systemPrompt === undefined && ["scout", "worker", "reviewer"].includes(request.role);
|
|
157
|
+
}
|
|
158
|
+
|
|
151
159
|
function toolsFor(request: AgentRequest): readonly string[] | undefined {
|
|
152
160
|
const tools = roleSpec(request.role).tools;
|
|
153
|
-
const extra = [...(request.extraTools ?? []), ...(request.onAsk ? [ASK_TOOL] : [])];
|
|
161
|
+
const extra = [...(request.extraTools ?? []), ...(hasKnowledgeAccess(request) ? KNOWLEDGE_TOOLS : []), ...(request.onAsk ? [ASK_TOOL] : [])];
|
|
154
162
|
if (extra.length === 0) return tools;
|
|
155
163
|
return [...(tools ?? []), ...extra];
|
|
156
164
|
}
|
|
@@ -197,7 +205,7 @@ async function runAgentOnce(request: AgentRequest, run: ProcessRunner, attempt:
|
|
|
197
205
|
signal,
|
|
198
206
|
...(request.onAsk ? { onAsk: request.onAsk } : {}),
|
|
199
207
|
...(request.harness ? { harness: request.harness } : {}),
|
|
200
|
-
env: request.
|
|
208
|
+
env: { ...request.env, [TASK_ID_ENV]: request.taskId, ...(request.onAsk ? { [ASK_ENV]: "1" } : {}) },
|
|
201
209
|
stallTimeoutMs: request.stallTimeoutMs,
|
|
202
210
|
toolStallTimeoutMs: request.toolStallTimeoutMs,
|
|
203
211
|
onEvent: (event) => {
|