agent-lyceum 0.25.1 → 0.26.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 +2 -2
- package/README.zh-TW.md +2 -2
- package/dist/config.d.ts +15 -1
- package/dist/config.js +2 -0
- package/dist/doctor-instances.d.ts +4 -0
- package/dist/doctor-instances.js +63 -0
- package/dist/doctor.d.ts +2 -0
- package/dist/doctor.js +17 -1
- package/dist/instance-containment.d.ts +15 -0
- package/dist/instance-containment.js +36 -0
- package/dist/instances.d.ts +183 -0
- package/dist/instances.js +268 -0
- package/dist/mailbox.d.ts +32 -2
- package/dist/mailbox.js +76 -18
- package/dist/message-store.d.ts +47 -1
- package/dist/message-store.js +75 -7
- package/dist/paths.d.ts +5 -0
- package/dist/paths.js +23 -1
- package/dist/policy.d.ts +1 -0
- package/dist/policy.js +2 -2
- package/dist/prompt.d.ts +2 -0
- package/dist/prompt.js +38 -9
- package/dist/run-session.d.ts +33 -2
- package/dist/run-session.js +280 -12
- package/dist/run-store.d.ts +13 -2
- package/dist/run-store.js +17 -7
- package/dist/scaffold.d.ts +2 -0
- package/dist/scaffold.js +3 -0
- package/dist/schema.d.ts +4 -0
- package/dist/schema.js +2 -0
- package/dist/status.d.ts +7 -0
- package/dist/status.js +48 -14
- package/dist/worktree.d.ts +6 -0
- package/dist/worktree.js +14 -1
- package/package.json +1 -1
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { NAME_RE, isInside } from "./paths.js";
|
|
4
|
+
// Same-role dynamic instances (docs/specs/23-dynamic-instances.md). Pure functions only: the caller injects every file read.
|
|
5
|
+
/** Instances one role may have at the same time when `dispatcher.max_instances` is not written. */
|
|
6
|
+
export const DEFAULT_MAX_INSTANCES = 4;
|
|
7
|
+
/** Instance 1 is the role itself; the others are `<role>--<k>`. */
|
|
8
|
+
export const slotName = (role, k) => (k === 1 ? role : `${role}--${k}`);
|
|
9
|
+
/** `<role>--<k>` with k >= 2 (no leading zero) → its parts. A name like `fe--x` is just an agent, never an instance. */
|
|
10
|
+
export function parseSlotName(name) {
|
|
11
|
+
const m = /^(.+)--([2-9]|[1-9]\d+)$/.exec(name);
|
|
12
|
+
return m?.[1] && m[2] ? { role: m[1], k: Number(m[2]) } : undefined;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The most instances `role` may have in this run. `check(role, k)` is the caller's extra refusal for slot k (a path
|
|
16
|
+
* overlap, spec D-17): it returns a reason, and the cap stops before that slot. `opened` are instance names already in the
|
|
17
|
+
* run's slot table; they are not name clashes even though a bound project lists them among its agents.
|
|
18
|
+
*/
|
|
19
|
+
export function instanceCap(project, mode, role, check, opened = []) {
|
|
20
|
+
if (mode !== "worktree")
|
|
21
|
+
return { n: 1, reason: "agents take turns in the checkout, not in separate git worktrees" };
|
|
22
|
+
if (project.solo)
|
|
23
|
+
return { n: 1, reason: "a single agent runs alone" };
|
|
24
|
+
if (project.run?.layout !== "run")
|
|
25
|
+
return { n: 1, reason: "this run uses the legacy mailbox layout" };
|
|
26
|
+
if (role === project.lead)
|
|
27
|
+
return { n: 1, reason: `the lead (${role}) is never run as several instances` };
|
|
28
|
+
if (!NAME_RE.test(role))
|
|
29
|
+
return { n: 1, reason: `"${role}" is not a valid branch name, so its instances cannot be named` };
|
|
30
|
+
const max = project.dispatcher.max_instances ?? DEFAULT_MAX_INSTANCES;
|
|
31
|
+
for (let k = 2; k <= max; k++) {
|
|
32
|
+
const name = slotName(role, k);
|
|
33
|
+
if (name in project.agents && !opened.includes(name))
|
|
34
|
+
return { n: k - 1, reason: `agent "${name}" has the name instance ${k} of "${role}" would get, so "${role}" runs at most ${k - 1} instance(s) at a time` };
|
|
35
|
+
const refused = check?.(role, k);
|
|
36
|
+
if (refused)
|
|
37
|
+
return { n: k - 1, reason: `instance ${k} of "${role}" ${refused}` };
|
|
38
|
+
}
|
|
39
|
+
return { n: max };
|
|
40
|
+
}
|
|
41
|
+
const present = (v) => v !== undefined && v !== null && v !== "";
|
|
42
|
+
/**
|
|
43
|
+
* What a mail's `parallel` field means (spec 23.3). `senderIsLead` is the router's own knowledge (the outbox owner), never the
|
|
44
|
+
* mail's `from`. `invalid` is a lead's mistake (the mail is sent back); `ignored` mail is delivered as an ordinary one.
|
|
45
|
+
*/
|
|
46
|
+
export function parallelFlag(input) {
|
|
47
|
+
const { value } = input;
|
|
48
|
+
if (value === undefined)
|
|
49
|
+
return "absent";
|
|
50
|
+
if (!input.senderIsLead)
|
|
51
|
+
return { ignored: "not-lead" };
|
|
52
|
+
if (typeof value !== "boolean")
|
|
53
|
+
return "invalid";
|
|
54
|
+
if (!value)
|
|
55
|
+
return "absent";
|
|
56
|
+
if (input.type !== "task" || present(input.replyTo) || present(input.thread))
|
|
57
|
+
return { ignored: "not-a-new-task" };
|
|
58
|
+
if (input.cap.n <= 1)
|
|
59
|
+
return { ignored: `parallel tasks run one at a time in this run${input.cap.reason ? `: ${input.cap.reason}` : ""}` };
|
|
60
|
+
return "on";
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The instance of `role` that owns message `id`, so a revision goes back to it (spec 23.7.2 rule 1). Only instances the role has
|
|
64
|
+
* open count, then assign > from > about. Work from before the role was activated that involves it by name belongs to instance 1.
|
|
65
|
+
*/
|
|
66
|
+
export function ownersOf(id, role, ctx) {
|
|
67
|
+
const r = ctx.rec(id);
|
|
68
|
+
if (!r)
|
|
69
|
+
return undefined;
|
|
70
|
+
for (const slot of [r.assign, r.from, r.about])
|
|
71
|
+
if (slot !== undefined && ctx.opened.includes(slot))
|
|
72
|
+
return slot;
|
|
73
|
+
if (r.intent && (ctx.mark === undefined || r.intent.seq < ctx.mark) && involves(r.intent, role, ctx.claimAgent))
|
|
74
|
+
return ctx.opened[0] ?? role;
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
78
|
+
/**
|
|
79
|
+
* Whether a delivery already in the journal concerns `role` by its own name: sent to it, sent by it, or a dispatcher notice about
|
|
80
|
+
* it (a failed integration or a refused worktree). Uses only what the dispatcher wrote: journal fields and claim records.
|
|
81
|
+
*/
|
|
82
|
+
export function involves(intent, role, claimAgent) {
|
|
83
|
+
if (intent.to === role)
|
|
84
|
+
return true;
|
|
85
|
+
// the file is `<stamp>-<from>-<msg_id>.md` and a stamp has no "-", so what follows the first "-" is the sender
|
|
86
|
+
const tail = `-${intent.msg_id}.md`;
|
|
87
|
+
if (intent.file.endsWith(tail)) {
|
|
88
|
+
const head = intent.file.slice(0, -tail.length);
|
|
89
|
+
const dash = head.indexOf("-");
|
|
90
|
+
if (dash > 0 && head.slice(dash + 1) === role)
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
if (intent.source.startsWith("integration:")) {
|
|
94
|
+
const key = intent.source.slice("integration:".length);
|
|
95
|
+
if (!key || key.includes("/") || key.includes("\\") || key.includes(".."))
|
|
96
|
+
return false;
|
|
97
|
+
const agent = claimAgent(key);
|
|
98
|
+
if (agent !== undefined)
|
|
99
|
+
return agent === role;
|
|
100
|
+
return new RegExp(`^${escapeRe(role)}-\\d+$`).test(key);
|
|
101
|
+
}
|
|
102
|
+
if (intent.source.startsWith("refused:")) {
|
|
103
|
+
const parts = intent.source.split(":");
|
|
104
|
+
return parts.length >= 3 && parts[parts.length - 1] === role;
|
|
105
|
+
}
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Where each waiting message of one role goes (spec 23.7.2). A decision never depends on a later message; a message that has to
|
|
110
|
+
* wait only holds back the new threads behind it for the same destination.
|
|
111
|
+
*/
|
|
112
|
+
export function planAssignments(input) {
|
|
113
|
+
const opened = [...input.opened];
|
|
114
|
+
const taken = new Set();
|
|
115
|
+
const out = { assign: [], waiting: [] };
|
|
116
|
+
// Where this plan already put each message: a later mail of the same batch that replies to it follows it before the journal is asked.
|
|
117
|
+
const planned = new Map();
|
|
118
|
+
const place = (m, a) => {
|
|
119
|
+
taken.add(a.slot);
|
|
120
|
+
planned.set(m.id, a.slot);
|
|
121
|
+
out.assign.push(a);
|
|
122
|
+
};
|
|
123
|
+
let parallelWait;
|
|
124
|
+
const wait = (m, reason) => out.waiting.push({ file: m.file, reason });
|
|
125
|
+
for (const m of input.held) {
|
|
126
|
+
const done = input.recorded(m.id);
|
|
127
|
+
if (done !== undefined) {
|
|
128
|
+
place(m, { file: m.file, slot: done, rule: "recorded" });
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
let owner;
|
|
132
|
+
for (const c of [m.replyTo, m.thread]) {
|
|
133
|
+
if (!c || c === m.id)
|
|
134
|
+
continue;
|
|
135
|
+
owner = planned.get(c) ?? input.owners(c);
|
|
136
|
+
if (owner !== undefined)
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
if (owner !== undefined) {
|
|
140
|
+
place(m, { file: m.file, slot: owner, rule: "affinity", ...(m.to !== input.role && m.to !== owner ? { addressed: m.to } : {}) });
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (m.to !== input.role) {
|
|
144
|
+
const a = opened.includes(m.to) ? input.acceptsNew(m.to) : "unknown-slot";
|
|
145
|
+
if (a === true) {
|
|
146
|
+
place(m, { file: m.file, slot: m.to, rule: "addressed" });
|
|
147
|
+
}
|
|
148
|
+
else
|
|
149
|
+
wait(m, a);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (m.parallel) {
|
|
153
|
+
if (parallelWait !== undefined) {
|
|
154
|
+
wait(m, parallelWait);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
const free = opened.find((s) => input.acceptsNew(s) === true && !input.busy(s) && !taken.has(s));
|
|
158
|
+
if (free !== undefined) {
|
|
159
|
+
place(m, { file: m.file, slot: free, rule: "parallel" });
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
let why;
|
|
163
|
+
if (opened.length < input.cap) {
|
|
164
|
+
const name = slotName(input.role, opened.length + 1);
|
|
165
|
+
const may = input.coolingDown ? "cooldown" : input.mayOpen(name);
|
|
166
|
+
if (may === true) {
|
|
167
|
+
opened.push(name);
|
|
168
|
+
place(m, { file: m.file, slot: name, rule: "parallel", open: name });
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
why = may;
|
|
172
|
+
}
|
|
173
|
+
else
|
|
174
|
+
why = opened.some((s) => input.acceptsNew(s) === true) ? "busy" : "cap";
|
|
175
|
+
parallelWait = why;
|
|
176
|
+
wait(m, why);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const a = input.acceptsNew(input.role);
|
|
180
|
+
if (a === true) {
|
|
181
|
+
place(m, { file: m.file, slot: input.role, rule: "default" });
|
|
182
|
+
}
|
|
183
|
+
else
|
|
184
|
+
wait(m, a);
|
|
185
|
+
}
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* The agents of a run with its opened instances added (spec 23.6): each `<role>--<k>` follows its role, copies the role's settings, keeps only
|
|
190
|
+
* its own task memory (the role's global/project memory become read-only `sharedMemory`) and cannot edit AGENT.md. Roles of the table that
|
|
191
|
+
* are not agents any more are skipped (a resume refuses those before it binds, `assertInstancesResumable`).
|
|
192
|
+
*/
|
|
193
|
+
export function synthesizeSlots(agents, table, taskMemory) {
|
|
194
|
+
const out = {};
|
|
195
|
+
for (const [name, a] of Object.entries(agents)) {
|
|
196
|
+
const slots = table.roles[name];
|
|
197
|
+
if (!slots) {
|
|
198
|
+
out[name] = a;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
out[name] = { ...a, instance: { role: name, k: 1 } };
|
|
202
|
+
for (const slot of slots) {
|
|
203
|
+
const parsed = parseSlotName(slot);
|
|
204
|
+
if (!parsed || parsed.role !== name)
|
|
205
|
+
continue;
|
|
206
|
+
out[slot] = {
|
|
207
|
+
...a,
|
|
208
|
+
name: slot,
|
|
209
|
+
memory: { task: taskMemory(slot) },
|
|
210
|
+
sharedMemory: { ...(a.memory.global ? { global: a.memory.global } : {}), ...(a.memory.project ? { project: a.memory.project } : {}) },
|
|
211
|
+
canEditAgentMd: false,
|
|
212
|
+
instance: { role: name, k: parsed.k },
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
/** The role an agent name stands for: the role itself, or the role of a bound instance. */
|
|
219
|
+
export const roleOfName = (agents, name) => agents[name]?.instance?.role ?? name;
|
|
220
|
+
/** How the lead is told who a mail came from (decision 8): a bound instance of a role with several shows its role and number. */
|
|
221
|
+
export function senderLabel(name, agents) {
|
|
222
|
+
const i = agents[name]?.instance;
|
|
223
|
+
return i ? `${name} (${i.role}, instance ${i.k})` : name;
|
|
224
|
+
}
|
|
225
|
+
/** Key of the one failure sent to the lead about this set of waiting mails (spec D-16). */
|
|
226
|
+
export const heldStallKey = (ids) => `held-stall:${crypto.createHash("sha256").update([...ids].sort().join("\n")).digest("hex").slice(0, 16)}`;
|
|
227
|
+
/**
|
|
228
|
+
* Refuse, before anything is written, to resume a run whose slot table no longer fits the configuration (spec 23.10): a role that
|
|
229
|
+
* is gone or is now the lead, an instance name that is now a configured agent, or an opened instance `check` finds a conflict for.
|
|
230
|
+
*/
|
|
231
|
+
export function assertInstancesResumable(base, table, runId, projectName, check) {
|
|
232
|
+
for (const [role, slots] of Object.entries(table.roles)) {
|
|
233
|
+
if (!(role in base.agents)) {
|
|
234
|
+
throw new Error(`Run ${runId} has instances of "${role}", which is no longer an agent of project ${projectName}. Add "${role}" back to project.yaml to finish or resume this run, or clear it.`);
|
|
235
|
+
}
|
|
236
|
+
if (role === base.lead)
|
|
237
|
+
throw new Error(`Run ${runId} has instances of "${role}", which is now the lead of project ${projectName}; the lead never runs as several instances. Make "${role}" a member again to resume this run, or clear it.`);
|
|
238
|
+
for (const slot of slots.slice(1)) {
|
|
239
|
+
if (slot in base.agents)
|
|
240
|
+
throw new Error(`Run ${runId} has the instance "${slot}" of "${role}", but project ${projectName} now has an agent of that name. Rename that agent to resume this run, or clear it.`);
|
|
241
|
+
const parsed = parseSlotName(slot);
|
|
242
|
+
const conflict = parsed ? check?.(role, parsed.k) : undefined;
|
|
243
|
+
if (conflict)
|
|
244
|
+
throw new Error(`Run ${runId} cannot be resumed: ${slot} ${conflict}`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
// ---- containment of the shared memory (spec 23.11, D-17) ----
|
|
249
|
+
/** Every directory an instance can write: what its policy allows, the folders of the files it may edit, and its working directory. */
|
|
250
|
+
export const slotWritableRoots = (pol, work) => [
|
|
251
|
+
...new Set([...pol.allowDirs, ...pol.allowFiles.map((f) => path.dirname(f)), ...work]),
|
|
252
|
+
];
|
|
253
|
+
/**
|
|
254
|
+
* The first pair of (shared memory path, writable root) where one contains the other, or undefined. `canon` resolves symlinks (the caller injects
|
|
255
|
+
* it); `caseInsensitive` compares in lower case (macOS volumes), which can only report a conflict that is not one, never miss one.
|
|
256
|
+
*/
|
|
257
|
+
export function containmentConflict(shared, roots, canon, caseInsensitive) {
|
|
258
|
+
const norm = (p) => (caseInsensitive ? canon(p).toLowerCase() : canon(p));
|
|
259
|
+
for (const s of shared) {
|
|
260
|
+
const cs = norm(s);
|
|
261
|
+
for (const w of roots) {
|
|
262
|
+
const cw = norm(w);
|
|
263
|
+
if (isInside(cs, cw) || isInside(cw, cs))
|
|
264
|
+
return { shared: s, root: w };
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return undefined;
|
|
268
|
+
}
|
package/dist/mailbox.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ResolvedProject } from "./config.js";
|
|
2
2
|
import { type Step } from "./format.js";
|
|
3
3
|
import { atomicWrite } from "./fs-util.js";
|
|
4
|
+
import { type InstanceCap } from "./instances.js";
|
|
4
5
|
import { RouteJournal } from "./message-store.js";
|
|
5
6
|
export { atomicWrite };
|
|
6
7
|
export declare const MESSAGE_TYPES: readonly ["task", "reply", "failure", "done", "ask"];
|
|
@@ -29,6 +30,8 @@ export declare function parseRaw(text: string): {
|
|
|
29
30
|
body: string;
|
|
30
31
|
};
|
|
31
32
|
export declare function ensureProjectDirs(project: ResolvedProject): void;
|
|
33
|
+
/** The mailbox and memory directories of one agent (a newly opened instance gets just its own). */
|
|
34
|
+
export declare function ensureAgentDirs(project: ResolvedProject, name: string): void;
|
|
32
35
|
export interface DeliverInput {
|
|
33
36
|
from: string;
|
|
34
37
|
to: string;
|
|
@@ -49,11 +52,22 @@ export declare function deliver(project: ResolvedProject, input: DeliverInput &
|
|
|
49
52
|
* the plan (id, file name, timestamp) is journaled before anything is written, a retry writes the identical file,
|
|
50
53
|
* and mail the recipient already read is never written again. Without a run (no journal) it just delivers.
|
|
51
54
|
*/
|
|
52
|
-
export declare function deliverOnce(project: ResolvedProject, journal: RouteJournal | undefined, sourceKey: string, input: DeliverInput): {
|
|
55
|
+
export declare function deliverOnce(project: ResolvedProject, journal: RouteJournal | undefined, sourceKey: string, input: DeliverInput, extra?: DeliverExtra): {
|
|
53
56
|
msg: Message;
|
|
54
57
|
fresh: boolean;
|
|
55
58
|
};
|
|
59
|
+
/** What a delivery records in the journal beyond the plain intent (spec 23.7.1); all of it only for roles with several instances. */
|
|
60
|
+
export interface DeliverExtra {
|
|
61
|
+
held?: boolean;
|
|
62
|
+
parallel?: boolean;
|
|
63
|
+
from?: string;
|
|
64
|
+
about?: string;
|
|
65
|
+
}
|
|
66
|
+
/** Mail for a role that has instances waits here, in the role's inbox, until the assigner places it. */
|
|
67
|
+
export declare const PENDING_DIR = "pending";
|
|
56
68
|
export declare function readMessage(file: string): Message;
|
|
69
|
+
/** `readMessage` for files that may be anything (held mail): a symlink, FIFO or other non-regular entry throws instead of being followed or blocking. */
|
|
70
|
+
export declare function readMessageNoFollow(file: string): Message;
|
|
57
71
|
/** Paths of the unread messages in an agent's inbox, oldest name first. Does not open them. */
|
|
58
72
|
export declare function unreadFiles(project: ResolvedProject, agent: string): string[];
|
|
59
73
|
export declare function listUnread(project: ResolvedProject, agent: string): Message[];
|
|
@@ -103,5 +117,21 @@ export interface RouteResult {
|
|
|
103
117
|
export declare function finishDone(file: string): void;
|
|
104
118
|
/** Set a `done` that broke the completion contract aside (in the outbox's `rejected/`). */
|
|
105
119
|
export declare function rejectDone(file: string): void;
|
|
120
|
+
/** What the router needs from the run to handle same-role instances (spec 23.7.1); a call without it routes as before. */
|
|
121
|
+
export interface InstanceRouting {
|
|
122
|
+
/** The most instances `role` may have, and why it is lower than configured. */
|
|
123
|
+
cap(role: string): InstanceCap;
|
|
124
|
+
/** The role already has instances: its mail waits in `pending/`. */
|
|
125
|
+
held(role: string): boolean;
|
|
126
|
+
/** Make the role's instances durable (slot table, schema_version 3) before any file of the mail that opens them is written. */
|
|
127
|
+
enable(role: string): void;
|
|
128
|
+
/** A `parallel` field that was delivered as an ordinary mail, and why. */
|
|
129
|
+
ignored(reason: string, meta: {
|
|
130
|
+
from: string;
|
|
131
|
+
id: string;
|
|
132
|
+
to: string;
|
|
133
|
+
subject: string;
|
|
134
|
+
}): void;
|
|
135
|
+
}
|
|
106
136
|
/** Validate each agent's outbox and move accepted mail into recipients' inboxes. Safe to run again after a crash. */
|
|
107
|
-
export declare function routeOutboxes(project: ResolvedProject): RouteResult;
|
|
137
|
+
export declare function routeOutboxes(project: ResolvedProject, routing?: InstanceRouting): RouteResult;
|
package/dist/mailbox.js
CHANGED
|
@@ -3,8 +3,9 @@ import fs from "node:fs";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import YAML from "yaml";
|
|
5
5
|
import { formatWarning, missingSections, parseSteps } from "./format.js";
|
|
6
|
-
import { atomicWrite } from "./fs-util.js";
|
|
6
|
+
import { atomicWrite, readRegularFileNoFollow } from "./fs-util.js";
|
|
7
7
|
import { countFormatRejection, formatRejectionKey, formatRejections, MAX_FORMAT_REJECTIONS } from "./format-rejections.js";
|
|
8
|
+
import { parallelFlag, roleOfName } from "./instances.js";
|
|
8
9
|
import { deterministicId, fault, RouteJournal, sourceIdOf } from "./message-store.js";
|
|
9
10
|
import { inboxDir, outboxDir, sharedDir } from "./policy.js";
|
|
10
11
|
import { activeRecordsBytes, RECORD_KINDS, RECORDS_BUDGET } from "./records.js";
|
|
@@ -38,14 +39,17 @@ export function ensureProjectDirs(project) {
|
|
|
38
39
|
const handoff = sharedDir(project);
|
|
39
40
|
if (handoff)
|
|
40
41
|
fs.mkdirSync(handoff, { recursive: true });
|
|
41
|
-
for (const name of Object.keys(project.agents))
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
42
|
+
for (const name of Object.keys(project.agents))
|
|
43
|
+
ensureAgentDirs(project, name);
|
|
44
|
+
}
|
|
45
|
+
/** The mailbox and memory directories of one agent (a newly opened instance gets just its own). */
|
|
46
|
+
export function ensureAgentDirs(project, name) {
|
|
47
|
+
fs.mkdirSync(path.join(inboxDir(project, name), "read"), { recursive: true });
|
|
48
|
+
fs.mkdirSync(path.join(outboxDir(project, name), "rejected"), { recursive: true });
|
|
49
|
+
const a = must(project.agents[name], `agent ${name}`);
|
|
50
|
+
for (const m of [a.memory.global, a.memory.project, a.memory.task])
|
|
51
|
+
if (m)
|
|
52
|
+
fs.mkdirSync(m, { recursive: true });
|
|
49
53
|
}
|
|
50
54
|
/** Deliver a message into `to`'s inbox (dispatcher-side; senders other than agents allowed). */
|
|
51
55
|
export function deliver(project, input) {
|
|
@@ -70,7 +74,7 @@ export function deliver(project, input) {
|
|
|
70
74
|
* the plan (id, file name, timestamp) is journaled before anything is written, a retry writes the identical file,
|
|
71
75
|
* and mail the recipient already read is never written again. Without a run (no journal) it just delivers.
|
|
72
76
|
*/
|
|
73
|
-
export function deliverOnce(project, journal, sourceKey, input) {
|
|
77
|
+
export function deliverOnce(project, journal, sourceKey, input, extra = {}) {
|
|
74
78
|
if (!journal)
|
|
75
79
|
return { msg: deliver(project, input), fresh: true };
|
|
76
80
|
const prior = journal.get(sourceKey, input.to);
|
|
@@ -81,14 +85,23 @@ export function deliverOnce(project, journal, sourceKey, input) {
|
|
|
81
85
|
msg_id: deterministicId(sourceKey, input.to),
|
|
82
86
|
file: `${stamp()}-${input.from}-${deterministicId(sourceKey, input.to)}.md`,
|
|
83
87
|
created: new Date().toISOString(),
|
|
88
|
+
...(extra.held ? { held: true } : {}),
|
|
89
|
+
...(extra.parallel ? { parallel: true } : {}),
|
|
90
|
+
...(extra.from ? { from: extra.from } : {}),
|
|
91
|
+
...(extra.about ? { about: extra.about } : {}),
|
|
84
92
|
});
|
|
85
93
|
fault("route:intent");
|
|
86
|
-
|
|
94
|
+
// A replay goes where the first attempt was planned to go: the journal's `held`, not the current state of the run.
|
|
95
|
+
const held = planned.held === true;
|
|
96
|
+
const inbox = inboxDir(project, held ? roleOfName(project.agents, input.to) : input.to);
|
|
97
|
+
const dest = path.join(held ? path.join(inbox, PENDING_DIR) : inbox, planned.file);
|
|
87
98
|
const meta = deliveredMeta(project, input, planned);
|
|
88
99
|
const want = serialize(meta, input.body);
|
|
89
100
|
const alreadyRead = fs.existsSync(path.join(path.dirname(dest), "read", planned.file));
|
|
101
|
+
// A held mail that was delivered (or assigned) may have been moved out of `pending/` already: it is never written again.
|
|
102
|
+
const settled = held && (planned.delivered || journal.assigned(planned.msg_id) !== undefined);
|
|
90
103
|
// Write the planned file; if one is already there, verify it instead of trusting it. Mail already read is never written again.
|
|
91
|
-
if (!alreadyRead && (!fs.existsSync(dest) || fs.readFileSync(dest, "utf8") !== want))
|
|
104
|
+
if (!settled && !alreadyRead && (!fs.existsSync(dest) || fs.readFileSync(dest, "utf8") !== want))
|
|
92
105
|
atomicWrite(dest, want);
|
|
93
106
|
fault("route:written");
|
|
94
107
|
const fresh = !planned.delivered;
|
|
@@ -97,6 +110,8 @@ export function deliverOnce(project, journal, sourceKey, input) {
|
|
|
97
110
|
fault("route:delivered");
|
|
98
111
|
return { msg: { meta, body: input.body, file: dest }, fresh };
|
|
99
112
|
}
|
|
113
|
+
/** Mail for a role that has instances waits here, in the role's inbox, until the assigner places it. */
|
|
114
|
+
export const PENDING_DIR = "pending";
|
|
100
115
|
function deliveredMeta(project, input, planned) {
|
|
101
116
|
return {
|
|
102
117
|
id: planned.msg_id,
|
|
@@ -115,6 +130,14 @@ export function readMessage(file) {
|
|
|
115
130
|
const meta = data;
|
|
116
131
|
return { meta, body, file };
|
|
117
132
|
}
|
|
133
|
+
/** `readMessage` for files that may be anything (held mail): a symlink, FIFO or other non-regular entry throws instead of being followed or blocking. */
|
|
134
|
+
export function readMessageNoFollow(file) {
|
|
135
|
+
const buf = readRegularFileNoFollow(file);
|
|
136
|
+
if (!buf)
|
|
137
|
+
throw new Error(`${file} is not a readable regular file`);
|
|
138
|
+
const { data, body } = parseRaw(buf.toString("utf8"));
|
|
139
|
+
return { meta: data, body, file };
|
|
140
|
+
}
|
|
118
141
|
/** Paths of the unread messages in an agent's inbox, oldest name first. Does not open them. */
|
|
119
142
|
export function unreadFiles(project, agent) {
|
|
120
143
|
const dir = inboxDir(project, agent);
|
|
@@ -176,12 +199,15 @@ function sameText(body) {
|
|
|
176
199
|
* An earlier mail from `from` to `to` with the same type, the same reply target and the same text (unread or already read):
|
|
177
200
|
* sending it again changes nothing and is how two agents end up in a loop. Texts too short to mean anything are never duplicates.
|
|
178
201
|
*/
|
|
179
|
-
function earlierDuplicate(project, from, to, type, replyTo, body) {
|
|
202
|
+
function earlierDuplicate(project, from, to, type, replyTo, body, held = false) {
|
|
180
203
|
const text = sameText(body);
|
|
181
204
|
if (text.length < 20)
|
|
182
205
|
return undefined;
|
|
183
206
|
const dir = inboxDir(project, to);
|
|
184
|
-
|
|
207
|
+
const role = roleOfName(project.agents, to);
|
|
208
|
+
const slots = held ? Object.values(project.agents).filter((a) => roleOfName(project.agents, a.name) === role).map((a) => inboxDir(project, a.name)) : [dir];
|
|
209
|
+
const dirs = held ? [...slots.flatMap((d) => [d, path.join(d, "read")]), path.join(inboxDir(project, role), PENDING_DIR)] : [dir, path.join(dir, "read")];
|
|
210
|
+
for (const d of dirs) {
|
|
185
211
|
if (!fs.existsSync(d))
|
|
186
212
|
continue;
|
|
187
213
|
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
|
@@ -200,7 +226,7 @@ function earlierDuplicate(project, from, to, type, replyTo, body) {
|
|
|
200
226
|
return undefined;
|
|
201
227
|
}
|
|
202
228
|
/** Validate each agent's outbox and move accepted mail into recipients' inboxes. Safe to run again after a crash. */
|
|
203
|
-
export function routeOutboxes(project) {
|
|
229
|
+
export function routeOutboxes(project, routing) {
|
|
204
230
|
const res = { delivered: [], rejected: [], warnings: [], asks: [] };
|
|
205
231
|
const journal = project.run ? new RouteJournal(project.run.dir) : undefined;
|
|
206
232
|
for (const sender of Object.values(project.agents)) {
|
|
@@ -276,7 +302,15 @@ export function routeOutboxes(project) {
|
|
|
276
302
|
reject(`missing "to".`);
|
|
277
303
|
continue;
|
|
278
304
|
}
|
|
279
|
-
const
|
|
305
|
+
const roleOf = (n) => roleOfName(project.agents, n);
|
|
306
|
+
if (sender.instance) {
|
|
307
|
+
const same = targets.find((t) => t !== sender.name && roleOf(t) === roleOf(sender.name));
|
|
308
|
+
if (same !== undefined) {
|
|
309
|
+
reject(`cannot message another instance of your own role ("${same}" is an instance of "${roleOf(sender.name)}"). Put what it needs in your reply to the lead.`);
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const bad = targets.find((t) => !project.agents[t] || t === sender.name || (sender.canMessage !== "all" && !sender.canMessage.includes(roleOf(t))));
|
|
280
314
|
if (bad !== undefined) {
|
|
281
315
|
const why = !project.agents[bad]
|
|
282
316
|
? `no such agent "${bad}"`
|
|
@@ -286,6 +320,18 @@ export function routeOutboxes(project) {
|
|
|
286
320
|
reject(why + ".");
|
|
287
321
|
continue;
|
|
288
322
|
}
|
|
323
|
+
// `parallel` is read here from the outbox file, but only a verdict on it is kept: it goes to the journal (never back into the mail).
|
|
324
|
+
const flags = new Map();
|
|
325
|
+
if (routing && d.parallel !== undefined) {
|
|
326
|
+
for (const t of targets) {
|
|
327
|
+
const role = roleOf(t);
|
|
328
|
+
flags.set(t, parallelFlag({ value: d.parallel, senderIsLead: sender.name === project.lead, type, replyTo: d.reply_to, thread: d.thread, cap: routing.cap(role) }));
|
|
329
|
+
}
|
|
330
|
+
if ([...flags.values()].includes("invalid")) {
|
|
331
|
+
reject(`parallel must be true or false (YAML boolean), not ${JSON.stringify(d.parallel)}. Write \`parallel: true\` on a new task to run it in a parallel instance, or leave the line out.`);
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
289
335
|
const missing = missingSections(type, parsed.body);
|
|
290
336
|
if (missing.length && project.run?.layout === "run") {
|
|
291
337
|
const key = formatRejectionKey(sender.name, type, subject);
|
|
@@ -302,7 +348,7 @@ export function routeOutboxes(project) {
|
|
|
302
348
|
for (const to of targets) {
|
|
303
349
|
if (journal?.get(source, to))
|
|
304
350
|
continue;
|
|
305
|
-
const earlier = earlierDuplicate(project, sender.name, to, type, replyTo, parsed.body);
|
|
351
|
+
const earlier = earlierDuplicate(project, sender.name, to, type, replyTo, parsed.body, !!routing?.held(roleOf(to)));
|
|
306
352
|
if (earlier)
|
|
307
353
|
repeats.set(to, earlier);
|
|
308
354
|
}
|
|
@@ -333,7 +379,19 @@ export function routeOutboxes(project) {
|
|
|
333
379
|
for (const to of targets) {
|
|
334
380
|
if (repeats.has(to))
|
|
335
381
|
continue;
|
|
336
|
-
const
|
|
382
|
+
const flag = flags.get(to);
|
|
383
|
+
const role = roleOf(to);
|
|
384
|
+
if (typeof flag === "object" && !journal?.get(source, to))
|
|
385
|
+
routing?.ignored(flag.ignored, { from: sender.name, id: deterministicId(source, to), to, subject });
|
|
386
|
+
// The first valid parallel task makes the role's instances durable before a single file of this mail exists.
|
|
387
|
+
if (routing && flag === "on" && !routing.held(role))
|
|
388
|
+
routing.enable(role);
|
|
389
|
+
const held = !!routing?.held(role);
|
|
390
|
+
const { msg, fresh } = deliverOnce(project, journal, source, { from: sender.name, to, type, subject, body, thread, reply_to: replyTo }, {
|
|
391
|
+
...(held ? { held: true } : {}),
|
|
392
|
+
...(held && flag === "on" ? { parallel: true } : {}),
|
|
393
|
+
...(routing?.held(roleOf(sender.name)) ? { from: sender.name } : {}),
|
|
394
|
+
});
|
|
337
395
|
if (!fresh)
|
|
338
396
|
continue;
|
|
339
397
|
res.delivered.push({ from: sender.name, to, id: msg.meta.id, file: msg.file, type, subject });
|
package/dist/message-store.d.ts
CHANGED
|
@@ -9,15 +9,61 @@ export interface JournalEntry {
|
|
|
9
9
|
file: string;
|
|
10
10
|
created: string;
|
|
11
11
|
delivered: boolean;
|
|
12
|
+
/** Same-role instances (spec 23): the mail waits in `inbox/<role>/pending/` for the assigner. Only written for an activated role. */
|
|
13
|
+
held?: true;
|
|
14
|
+
/** The lead marked it `parallel: true` (read only from here, never from the mail file). */
|
|
15
|
+
parallel?: true;
|
|
16
|
+
/** The instance of an activated role that sent it. */
|
|
17
|
+
from?: string;
|
|
18
|
+
/** The instance a dispatcher notice is about. */
|
|
19
|
+
about?: string;
|
|
20
|
+
/** Position of the intent among the journal's records (a line cut short by a crash does not count). */
|
|
21
|
+
seq?: number;
|
|
22
|
+
}
|
|
23
|
+
/** What the journal knows about one message id: where the assigner put it, who sent it or what it is about, and its delivery line. */
|
|
24
|
+
export interface Ownership {
|
|
25
|
+
assign?: string;
|
|
26
|
+
from?: string;
|
|
27
|
+
about?: string;
|
|
28
|
+
intent?: {
|
|
29
|
+
seq: number;
|
|
30
|
+
to: string;
|
|
31
|
+
file: string;
|
|
32
|
+
source: string;
|
|
33
|
+
msg_id: string;
|
|
34
|
+
};
|
|
12
35
|
}
|
|
13
36
|
/** Append-only log of deliveries. A delivery is identified by `(source, recipient)`, never by a random id. */
|
|
14
37
|
export declare class RouteJournal {
|
|
15
38
|
private readonly file;
|
|
16
39
|
private readonly entries;
|
|
40
|
+
private readonly byMsg;
|
|
41
|
+
private readonly byFileName;
|
|
42
|
+
private readonly assigns;
|
|
43
|
+
private readonly firstHeldSeq;
|
|
44
|
+
private seqNext;
|
|
17
45
|
constructor(runDir: string);
|
|
46
|
+
/** Enter an intent in every index; `seq` is its position among the records read or appended. */
|
|
47
|
+
private index;
|
|
18
48
|
get(source: string, to: string): JournalEntry | undefined;
|
|
19
49
|
private append;
|
|
20
|
-
intent(e: Omit<JournalEntry, "delivered">): JournalEntry;
|
|
50
|
+
intent(e: Omit<JournalEntry, "delivered" | "seq">): JournalEntry;
|
|
51
|
+
/** The assigner's decision for a held mail, made durable before the file moves (spec 23.7.2). */
|
|
52
|
+
assign(a: {
|
|
53
|
+
msg_id: string;
|
|
54
|
+
file: string;
|
|
55
|
+
role: string;
|
|
56
|
+
slot: string;
|
|
57
|
+
}): void;
|
|
58
|
+
assigned(msgId: string): {
|
|
59
|
+
slot: string;
|
|
60
|
+
file: string;
|
|
61
|
+
role: string;
|
|
62
|
+
} | undefined;
|
|
63
|
+
byFile(file: string): JournalEntry | undefined;
|
|
64
|
+
/** Position of the first held intent addressed to `name`; undefined when there is none. */
|
|
65
|
+
firstHeld(name: string): number | undefined;
|
|
66
|
+
ownership(msgId: string): Ownership | undefined;
|
|
21
67
|
delivered(source: string, to: string): void;
|
|
22
68
|
}
|
|
23
69
|
/** Stable id of one outbox file instance: who wrote it, its name, when, and what it said. */
|