agent-lyceum 0.25.1 → 0.27.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 +6 -2
- package/README.zh-TW.md +6 -2
- package/dist/call.js +2 -0
- package/dist/config.d.ts +29 -1
- package/dist/config.js +34 -3
- 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/duplicate-notices.d.ts +9 -0
- package/dist/duplicate-notices.js +34 -0
- package/dist/fallback.d.ts +4 -2
- package/dist/fallback.js +11 -4
- package/dist/guard.d.ts +17 -1
- package/dist/guard.js +37 -6
- 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 +46 -2
- package/dist/mailbox.js +121 -36
- package/dist/message-store.d.ts +47 -1
- package/dist/message-store.js +75 -7
- package/dist/paths.d.ts +7 -0
- package/dist/paths.js +27 -2
- package/dist/policy.d.ts +1 -0
- package/dist/policy.js +2 -2
- package/dist/prompt.d.ts +11 -0
- package/dist/prompt.js +66 -10
- package/dist/run-session.d.ts +35 -2
- package/dist/run-session.js +337 -20
- package/dist/run-store.d.ts +17 -3
- package/dist/run-store.js +17 -7
- package/dist/scaffold.d.ts +3 -0
- package/dist/scaffold.js +10 -1
- package/dist/schema.d.ts +19 -0
- package/dist/schema.js +12 -0
- package/dist/status.d.ts +7 -0
- package/dist/status.js +74 -15
- package/dist/tiers.d.ts +27 -0
- package/dist/tiers.js +49 -0
- package/dist/validate.js +28 -1
- package/dist/worktree.d.ts +6 -0
- package/dist/worktree.js +16 -2
- package/package.json +1 -1
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { containmentConflict, slotName, slotWritableRoots } from "./instances.js";
|
|
2
|
+
import { canonicalPath } from "./paths.js";
|
|
3
|
+
import { writePolicy } from "./policy.js";
|
|
4
|
+
import { bindRunProject } from "./run-store.js";
|
|
5
|
+
import { recordedGitDir, workspaceRoot } from "./worktree.js";
|
|
6
|
+
/**
|
|
7
|
+
* D-17 as one object per run: reads the file system only through `canonicalPath` (lstat/realpath), writes nothing. The shared memory of a role
|
|
8
|
+
* is its `memory.global`/`memory.project`; an instance k >= 2 may write its task memory, outbox, worktree and git dir, none of which may
|
|
9
|
+
* contain or lie inside the shared memory (the OS sandbox and the Codex writable roots are built from the same paths).
|
|
10
|
+
*/
|
|
11
|
+
export function instanceContainment(base, runDir, layout, platform = process.platform) {
|
|
12
|
+
const runId = runDir.split(/[\\/]/).filter(Boolean).pop() ?? "";
|
|
13
|
+
const judge = (role, k, work) => {
|
|
14
|
+
const agent = base.agents[role];
|
|
15
|
+
const shared = [agent?.memory.global, agent?.memory.project].filter((x) => !!x);
|
|
16
|
+
if (!shared.length || k < 2)
|
|
17
|
+
return undefined;
|
|
18
|
+
const slots = Array.from({ length: k }, (_, i) => slotName(role, i + 1));
|
|
19
|
+
const bound = bindRunProject(base, runDir, layout, { roles: { [role]: slots } });
|
|
20
|
+
const inst = bound.agents[slotName(role, k)];
|
|
21
|
+
if (!inst)
|
|
22
|
+
return undefined;
|
|
23
|
+
const roots = slotWritableRoots(writePolicy(bound, inst), work);
|
|
24
|
+
const c = containmentConflict(shared, roots, canonicalPath, platform === "darwin");
|
|
25
|
+
return c ? `could write the shared memory ${c.shared} of "${role}": its writable path ${c.root} contains it or lies inside it` : undefined;
|
|
26
|
+
};
|
|
27
|
+
return {
|
|
28
|
+
check: (role, k) => {
|
|
29
|
+
// An instance that already has a worktree is judged on the git dir recorded for it (read-only), not only on the predicted root.
|
|
30
|
+
const slot = slotName(role, k);
|
|
31
|
+
const gitDir = recordedGitDir(base, runId, slot);
|
|
32
|
+
return judge(role, k, [workspaceRoot(base, runId, slot), ...(gitDir ? [gitDir] : [])]);
|
|
33
|
+
},
|
|
34
|
+
checkWorkspace: (role, k, ws) => judge(role, k, [ws.root, ws.gitDir]),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import type { ResolvedAgent, ResolvedProject } from "./config.js";
|
|
2
|
+
import type { WorkspaceMode } from "./worktree.js";
|
|
3
|
+
/** Instances one role may have at the same time when `dispatcher.max_instances` is not written. */
|
|
4
|
+
export declare const DEFAULT_MAX_INSTANCES = 4;
|
|
5
|
+
/** Instance 1 is the role itself; the others are `<role>--<k>`. */
|
|
6
|
+
export declare const slotName: (role: string, k: number) => string;
|
|
7
|
+
/** `<role>--<k>` with k >= 2 (no leading zero) → its parts. A name like `fe--x` is just an agent, never an instance. */
|
|
8
|
+
export declare function parseSlotName(name: string): {
|
|
9
|
+
role: string;
|
|
10
|
+
k: number;
|
|
11
|
+
} | undefined;
|
|
12
|
+
export interface InstanceCap {
|
|
13
|
+
/** How many instances the role may have at once (1 = no parallel). */
|
|
14
|
+
n: number;
|
|
15
|
+
/** Why `n` is 1 (or lower than configured); set whenever the limit is not simply the configured one. */
|
|
16
|
+
reason?: string;
|
|
17
|
+
}
|
|
18
|
+
type CapProject = Pick<ResolvedProject, "lead" | "solo" | "run" | "dispatcher"> & {
|
|
19
|
+
agents: Record<string, unknown>;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* The most instances `role` may have in this run. `check(role, k)` is the caller's extra refusal for slot k (a path
|
|
23
|
+
* overlap, spec D-17): it returns a reason, and the cap stops before that slot. `opened` are instance names already in the
|
|
24
|
+
* run's slot table; they are not name clashes even though a bound project lists them among its agents.
|
|
25
|
+
*/
|
|
26
|
+
export declare function instanceCap(project: CapProject, mode: WorkspaceMode, role: string, check?: (role: string, k: number) => string | undefined, opened?: readonly string[]): InstanceCap;
|
|
27
|
+
export type ParallelFlag = "absent" | "invalid" | "on" | {
|
|
28
|
+
ignored: string;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* What a mail's `parallel` field means (spec 23.3). `senderIsLead` is the router's own knowledge (the outbox owner), never the
|
|
32
|
+
* mail's `from`. `invalid` is a lead's mistake (the mail is sent back); `ignored` mail is delivered as an ordinary one.
|
|
33
|
+
*/
|
|
34
|
+
export declare function parallelFlag(input: {
|
|
35
|
+
value: unknown;
|
|
36
|
+
senderIsLead: boolean;
|
|
37
|
+
type: string;
|
|
38
|
+
replyTo?: unknown;
|
|
39
|
+
thread?: unknown;
|
|
40
|
+
cap: InstanceCap;
|
|
41
|
+
}): ParallelFlag;
|
|
42
|
+
/** What the route journal holds about one message id; every field is written by the dispatcher, never read from mail text. */
|
|
43
|
+
export interface OwnerRecord {
|
|
44
|
+
/** Slot the assigner placed it in. */
|
|
45
|
+
assign?: string;
|
|
46
|
+
/** Instance that sent it. */
|
|
47
|
+
from?: string;
|
|
48
|
+
/** Instance a dispatcher notice is about. */
|
|
49
|
+
about?: string;
|
|
50
|
+
/** Its delivery (intent) line: order in the journal, recipient, inbox file name, route source key. */
|
|
51
|
+
intent?: {
|
|
52
|
+
seq: number;
|
|
53
|
+
to: string;
|
|
54
|
+
file: string;
|
|
55
|
+
source: string;
|
|
56
|
+
msg_id: string;
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
export interface OwnersCtx {
|
|
60
|
+
/** `state.instances.roles[role]`: the role's open instances, `[0]` is the role itself. */
|
|
61
|
+
opened: readonly string[];
|
|
62
|
+
rec: (id: string) => OwnerRecord | undefined;
|
|
63
|
+
/** Journal position of the role's first held intent (its activation); undefined while it is not in the journal yet. */
|
|
64
|
+
mark: number | undefined;
|
|
65
|
+
/** `agent` of `mail/claims/<id>.json`; undefined when there is no readable claim. */
|
|
66
|
+
claimAgent: (claimId: string) => string | undefined;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* 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
|
|
70
|
+
* open count, then assign > from > about. Work from before the role was activated that involves it by name belongs to instance 1.
|
|
71
|
+
*/
|
|
72
|
+
export declare function ownersOf(id: string, role: string, ctx: OwnersCtx): string | undefined;
|
|
73
|
+
/**
|
|
74
|
+
* Whether a delivery already in the journal concerns `role` by its own name: sent to it, sent by it, or a dispatcher notice about
|
|
75
|
+
* it (a failed integration or a refused worktree). Uses only what the dispatcher wrote: journal fields and claim records.
|
|
76
|
+
*/
|
|
77
|
+
export declare function involves(intent: {
|
|
78
|
+
to: string;
|
|
79
|
+
file: string;
|
|
80
|
+
source: string;
|
|
81
|
+
msg_id: string;
|
|
82
|
+
}, role: string, claimAgent: (claimId: string) => string | undefined): boolean;
|
|
83
|
+
/** A message waiting in `mail/inbox/<role>/pending/`, as the journal describes it. */
|
|
84
|
+
export interface HeldMsg {
|
|
85
|
+
file: string;
|
|
86
|
+
id: string;
|
|
87
|
+
/** The recipient name on the mail: the role or one of its instances. */
|
|
88
|
+
to: string;
|
|
89
|
+
replyTo?: string;
|
|
90
|
+
thread?: string;
|
|
91
|
+
/** The journal says the lead marked it `parallel: true`. */
|
|
92
|
+
parallel: boolean;
|
|
93
|
+
}
|
|
94
|
+
export type Acceptance = true | "blocked" | "refused" | "cap";
|
|
95
|
+
export interface PlanInput {
|
|
96
|
+
role: string;
|
|
97
|
+
/** Waiting mail in delivery (file name) order. */
|
|
98
|
+
held: readonly HeldMsg[];
|
|
99
|
+
/** The role's open instances, `[0]` is the role itself. */
|
|
100
|
+
opened: readonly string[];
|
|
101
|
+
/** Effective instance limit. */
|
|
102
|
+
cap: number;
|
|
103
|
+
/** `ownersOf(id, role)` with everything but the id bound. */
|
|
104
|
+
owners: (id: string) => string | undefined;
|
|
105
|
+
/** The slot the journal already recorded for a message. */
|
|
106
|
+
recorded: (id: string) => string | undefined;
|
|
107
|
+
/** Whether a slot may take a new thread (not refused, not blocked, within the cap). */
|
|
108
|
+
acceptsNew: (slot: string) => Acceptance;
|
|
109
|
+
/** The slot's inbox has unread mail. */
|
|
110
|
+
busy: (slot: string) => boolean;
|
|
111
|
+
/** The role's primary model is cooling down after a rate limit. */
|
|
112
|
+
coolingDown: boolean;
|
|
113
|
+
/** Whether a new instance of that name may be opened. */
|
|
114
|
+
mayOpen: (name: string) => true | "collision" | "containment";
|
|
115
|
+
}
|
|
116
|
+
export interface Assignment {
|
|
117
|
+
file: string;
|
|
118
|
+
slot: string;
|
|
119
|
+
rule: "recorded" | "affinity" | "addressed" | "parallel" | "default";
|
|
120
|
+
/** Set when the slot has to be opened first. */
|
|
121
|
+
open?: string;
|
|
122
|
+
/** Set when an affinity placed a message addressed to another instance. */
|
|
123
|
+
addressed?: string;
|
|
124
|
+
}
|
|
125
|
+
export interface Plan {
|
|
126
|
+
assign: Assignment[];
|
|
127
|
+
waiting: {
|
|
128
|
+
file: string;
|
|
129
|
+
reason: string;
|
|
130
|
+
}[];
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* 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
|
|
134
|
+
* wait only holds back the new threads behind it for the same destination.
|
|
135
|
+
*/
|
|
136
|
+
export declare function planAssignments(input: PlanInput): Plan;
|
|
137
|
+
/** A run's slot table: `roles[<role>]` lists the role's open instances, `[0]` is the role itself. */
|
|
138
|
+
export interface SlotTable {
|
|
139
|
+
roles: Record<string, string[]>;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* 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
|
|
143
|
+
* its own task memory (the role's global/project memory become read-only `sharedMemory`) and cannot edit AGENT.md. Roles of the table that
|
|
144
|
+
* are not agents any more are skipped (a resume refuses those before it binds, `assertInstancesResumable`).
|
|
145
|
+
*/
|
|
146
|
+
export declare function synthesizeSlots(agents: Record<string, ResolvedAgent>, table: SlotTable, taskMemory: (slot: string) => string): Record<string, ResolvedAgent>;
|
|
147
|
+
/** The role an agent name stands for: the role itself, or the role of a bound instance. */
|
|
148
|
+
export declare const roleOfName: (agents: Record<string, {
|
|
149
|
+
instance?: {
|
|
150
|
+
role: string;
|
|
151
|
+
};
|
|
152
|
+
} | undefined>, name: string) => string;
|
|
153
|
+
/** 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. */
|
|
154
|
+
export declare function senderLabel(name: string, agents: Record<string, {
|
|
155
|
+
instance?: {
|
|
156
|
+
role: string;
|
|
157
|
+
k: number;
|
|
158
|
+
};
|
|
159
|
+
} | undefined>): string;
|
|
160
|
+
/** Key of the one failure sent to the lead about this set of waiting mails (spec D-16). */
|
|
161
|
+
export declare const heldStallKey: (ids: readonly string[]) => string;
|
|
162
|
+
/**
|
|
163
|
+
* Refuse, before anything is written, to resume a run whose slot table no longer fits the configuration (spec 23.10): a role that
|
|
164
|
+
* 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.
|
|
165
|
+
*/
|
|
166
|
+
export declare function assertInstancesResumable(base: {
|
|
167
|
+
lead: string;
|
|
168
|
+
agents: Record<string, unknown>;
|
|
169
|
+
}, table: SlotTable, runId: string, projectName: string, check?: (role: string, k: number) => string | undefined): void;
|
|
170
|
+
/** Every directory an instance can write: what its policy allows, the folders of the files it may edit, and its working directory. */
|
|
171
|
+
export declare const slotWritableRoots: (pol: {
|
|
172
|
+
allowDirs: readonly string[];
|
|
173
|
+
allowFiles: readonly string[];
|
|
174
|
+
}, work: readonly string[]) => string[];
|
|
175
|
+
/**
|
|
176
|
+
* The first pair of (shared memory path, writable root) where one contains the other, or undefined. `canon` resolves symlinks (the caller injects
|
|
177
|
+
* it); `caseInsensitive` compares in lower case (macOS volumes), which can only report a conflict that is not one, never miss one.
|
|
178
|
+
*/
|
|
179
|
+
export declare function containmentConflict(shared: readonly string[], roots: readonly string[], canon: (p: string) => string, caseInsensitive: boolean): {
|
|
180
|
+
shared: string;
|
|
181
|
+
root: string;
|
|
182
|
+
} | undefined;
|
|
183
|
+
export {};
|
|
@@ -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,8 @@
|
|
|
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";
|
|
5
|
+
import type { Tier } from "./schema.js";
|
|
4
6
|
import { RouteJournal } from "./message-store.js";
|
|
5
7
|
export { atomicWrite };
|
|
6
8
|
export declare const MESSAGE_TYPES: readonly ["task", "reply", "failure", "done", "ask"];
|
|
@@ -16,6 +18,8 @@ export interface MessageMeta {
|
|
|
16
18
|
created: string;
|
|
17
19
|
/** The run this mail belongs to. Stamped by the dispatcher; absent on mail of legacy runs. */
|
|
18
20
|
run_id?: string;
|
|
21
|
+
/** The lead-chosen difficulty tier, validated by the dispatcher at delivery (spec 24.5.3). Absent = the ceiling. */
|
|
22
|
+
tier?: Tier;
|
|
19
23
|
}
|
|
20
24
|
export interface Message {
|
|
21
25
|
meta: MessageMeta;
|
|
@@ -29,6 +33,8 @@ export declare function parseRaw(text: string): {
|
|
|
29
33
|
body: string;
|
|
30
34
|
};
|
|
31
35
|
export declare function ensureProjectDirs(project: ResolvedProject): void;
|
|
36
|
+
/** The mailbox and memory directories of one agent (a newly opened instance gets just its own). */
|
|
37
|
+
export declare function ensureAgentDirs(project: ResolvedProject, name: string): void;
|
|
32
38
|
export interface DeliverInput {
|
|
33
39
|
from: string;
|
|
34
40
|
to: string;
|
|
@@ -37,6 +43,8 @@ export interface DeliverInput {
|
|
|
37
43
|
body: string;
|
|
38
44
|
thread?: string;
|
|
39
45
|
reply_to?: string;
|
|
46
|
+
/** Written to the mail only when set (spec 24.5.3), so mail without it is unchanged. */
|
|
47
|
+
tier?: Tier;
|
|
40
48
|
}
|
|
41
49
|
/** Deliver a message into `to`'s inbox (dispatcher-side; senders other than agents allowed). */
|
|
42
50
|
export declare function deliver(project: ResolvedProject, input: DeliverInput & {
|
|
@@ -49,11 +57,22 @@ export declare function deliver(project: ResolvedProject, input: DeliverInput &
|
|
|
49
57
|
* the plan (id, file name, timestamp) is journaled before anything is written, a retry writes the identical file,
|
|
50
58
|
* and mail the recipient already read is never written again. Without a run (no journal) it just delivers.
|
|
51
59
|
*/
|
|
52
|
-
export declare function deliverOnce(project: ResolvedProject, journal: RouteJournal | undefined, sourceKey: string, input: DeliverInput): {
|
|
60
|
+
export declare function deliverOnce(project: ResolvedProject, journal: RouteJournal | undefined, sourceKey: string, input: DeliverInput, extra?: DeliverExtra): {
|
|
53
61
|
msg: Message;
|
|
54
62
|
fresh: boolean;
|
|
55
63
|
};
|
|
64
|
+
/** What a delivery records in the journal beyond the plain intent (spec 23.7.1); all of it only for roles with several instances. */
|
|
65
|
+
export interface DeliverExtra {
|
|
66
|
+
held?: boolean;
|
|
67
|
+
parallel?: boolean;
|
|
68
|
+
from?: string;
|
|
69
|
+
about?: string;
|
|
70
|
+
}
|
|
71
|
+
/** Mail for a role that has instances waits here, in the role's inbox, until the assigner places it. */
|
|
72
|
+
export declare const PENDING_DIR = "pending";
|
|
56
73
|
export declare function readMessage(file: string): Message;
|
|
74
|
+
/** `readMessage` for files that may be anything (held mail): a symlink, FIFO or other non-regular entry throws instead of being followed or blocking. */
|
|
75
|
+
export declare function readMessageNoFollow(file: string): Message;
|
|
57
76
|
/** Paths of the unread messages in an agent's inbox, oldest name first. Does not open them. */
|
|
58
77
|
export declare function unreadFiles(project: ResolvedProject, agent: string): string[];
|
|
59
78
|
export declare function listUnread(project: ResolvedProject, agent: string): Message[];
|
|
@@ -98,10 +117,35 @@ export interface RouteResult {
|
|
|
98
117
|
}[];
|
|
99
118
|
/** Latest `## Steps` checklist the lead sent, if any. */
|
|
100
119
|
steps?: Step[];
|
|
120
|
+
/** `tier` fields delivered without effect (spec 24.5.2); the caller logs `tier_ignored` and sends the notes. */
|
|
121
|
+
tierIgnored: {
|
|
122
|
+
from: string;
|
|
123
|
+
id: string;
|
|
124
|
+
to: string;
|
|
125
|
+
subject: string;
|
|
126
|
+
type: string;
|
|
127
|
+
reason: "not-lead" | "not-a-task" | "no-tiers";
|
|
128
|
+
}[];
|
|
101
129
|
}
|
|
102
130
|
/** Move a handled `done` file out of the outbox (to `read/`). */
|
|
103
131
|
export declare function finishDone(file: string): void;
|
|
104
132
|
/** Set a `done` that broke the completion contract aside (in the outbox's `rejected/`). */
|
|
105
133
|
export declare function rejectDone(file: string): void;
|
|
134
|
+
/** What the router needs from the run to handle same-role instances (spec 23.7.1); a call without it routes as before. */
|
|
135
|
+
export interface InstanceRouting {
|
|
136
|
+
/** The most instances `role` may have, and why it is lower than configured. */
|
|
137
|
+
cap(role: string): InstanceCap;
|
|
138
|
+
/** The role already has instances: its mail waits in `pending/`. */
|
|
139
|
+
held(role: string): boolean;
|
|
140
|
+
/** Make the role's instances durable (slot table, schema_version 3) before any file of the mail that opens them is written. */
|
|
141
|
+
enable(role: string): void;
|
|
142
|
+
/** A `parallel` field that was delivered as an ordinary mail, and why. */
|
|
143
|
+
ignored(reason: string, meta: {
|
|
144
|
+
from: string;
|
|
145
|
+
id: string;
|
|
146
|
+
to: string;
|
|
147
|
+
subject: string;
|
|
148
|
+
}): void;
|
|
149
|
+
}
|
|
106
150
|
/** 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;
|
|
151
|
+
export declare function routeOutboxes(project: ResolvedProject, routing?: InstanceRouting): RouteResult;
|