harnery 0.14.0 → 0.15.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 +1 -0
- package/dist/commander.d.ts.map +1 -1
- package/dist/commander.js +2 -0
- package/dist/commands/supervisor.d.ts +4 -0
- package/dist/commands/supervisor.d.ts.map +1 -0
- package/dist/commands/supervisor.js +238 -0
- package/dist/core/supervisor/index.d.ts +3 -0
- package/dist/core/supervisor/index.d.ts.map +1 -0
- package/dist/core/supervisor/index.js +2 -0
- package/dist/core/supervisor/read.d.ts +2 -0
- package/dist/core/supervisor/read.d.ts.map +1 -0
- package/dist/core/supervisor/read.js +1 -0
- package/dist/core/supervisor/runner.d.ts +34 -0
- package/dist/core/supervisor/runner.d.ts.map +1 -0
- package/dist/core/supervisor/runner.js +198 -0
- package/dist/core/supervisor/state.d.ts +69 -0
- package/dist/core/supervisor/state.d.ts.map +1 -0
- package/dist/core/supervisor/state.js +413 -0
- package/dist/core/workflow/engine.d.ts.map +1 -1
- package/dist/core/workflow/engine.js +21 -8
- package/dist/core/workflow/index.d.ts +1 -1
- package/dist/core/workflow/index.d.ts.map +1 -1
- package/dist/core/workflow/proof.d.ts.map +1 -1
- package/dist/core/workflow/proof.js +1 -0
- package/dist/core/workflow/run-state.d.ts +2 -0
- package/dist/core/workflow/run-state.d.ts.map +1 -1
- package/dist/core/workflow/run-state.js +12 -0
- package/dist/core/workflow/specialists.d.ts +7 -0
- package/dist/core/workflow/specialists.d.ts.map +1 -0
- package/dist/core/workflow/specialists.js +78 -0
- package/dist/core/workflow/types.d.ts +17 -0
- package/dist/core/workflow/types.d.ts.map +1 -1
- package/package.json +11 -1
- package/src/commander.ts +2 -0
- package/src/commands/supervisor.ts +315 -0
- package/src/core/supervisor/index.ts +24 -0
- package/src/core/supervisor/read.ts +14 -0
- package/src/core/supervisor/runner.ts +278 -0
- package/src/core/supervisor/state.ts +552 -0
- package/src/core/workflow/engine.ts +21 -8
- package/src/core/workflow/index.ts +1 -0
- package/src/core/workflow/proof.ts +1 -0
- package/src/core/workflow/run-state.ts +17 -0
- package/src/core/workflow/specialists.ts +97 -0
- package/src/core/workflow/types.ts +18 -0
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
chmodSync,
|
|
4
|
+
closeSync,
|
|
5
|
+
existsSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
openSync,
|
|
8
|
+
readdirSync,
|
|
9
|
+
readFileSync,
|
|
10
|
+
statSync,
|
|
11
|
+
unlinkSync,
|
|
12
|
+
writeFileSync,
|
|
13
|
+
} from "node:fs";
|
|
14
|
+
import { hostname } from "node:os";
|
|
15
|
+
import { dirname, join, resolve } from "node:path";
|
|
16
|
+
import { assertWorkId, readWorkItem, type WorkRecord } from "../work/read.ts";
|
|
17
|
+
import { normalizeWorkflowSpecialists } from "../workflow/specialists.ts";
|
|
18
|
+
import type { WorkflowSpecialistProfile } from "../workflow/types.ts";
|
|
19
|
+
|
|
20
|
+
export const SUPERVISOR_INTENT_SCHEMA_VERSION = 1 as const;
|
|
21
|
+
|
|
22
|
+
const GOAL_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
|
|
23
|
+
const MAX_TITLE = 200;
|
|
24
|
+
const MAX_INTENT_BYTES = 256 * 1024;
|
|
25
|
+
const FOREIGN_LEASE_STALE_MS = 24 * 60 * 60 * 1_000;
|
|
26
|
+
|
|
27
|
+
export interface SupervisorLimits {
|
|
28
|
+
max_cycles: number;
|
|
29
|
+
max_runtime_ms: number;
|
|
30
|
+
max_parallel_work: number;
|
|
31
|
+
max_total_attempts: number;
|
|
32
|
+
max_agents_per_work: number;
|
|
33
|
+
agent_concurrency: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface SupervisorAutomationPolicy {
|
|
37
|
+
accept_passing_proof: boolean;
|
|
38
|
+
resume_approved: boolean;
|
|
39
|
+
retry_blocked: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface SupervisorIntent {
|
|
43
|
+
schema_version: typeof SUPERVISOR_INTENT_SCHEMA_VERSION;
|
|
44
|
+
id: string;
|
|
45
|
+
title: string;
|
|
46
|
+
root_work_id: string;
|
|
47
|
+
specialists: Record<string, WorkflowSpecialistProfile>;
|
|
48
|
+
limits: SupervisorLimits;
|
|
49
|
+
automation: SupervisorAutomationPolicy;
|
|
50
|
+
created_at: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type SupervisorState =
|
|
54
|
+
| "ready"
|
|
55
|
+
| "running"
|
|
56
|
+
| "awaiting_attention"
|
|
57
|
+
| "blocked"
|
|
58
|
+
| "budget_exhausted"
|
|
59
|
+
| "succeeded";
|
|
60
|
+
|
|
61
|
+
export type SupervisorNextAction =
|
|
62
|
+
| "run"
|
|
63
|
+
| "wait_for_run"
|
|
64
|
+
| "resolve_approval"
|
|
65
|
+
| "review"
|
|
66
|
+
| "retry"
|
|
67
|
+
| "none";
|
|
68
|
+
|
|
69
|
+
export interface SupervisorProjection {
|
|
70
|
+
id: string;
|
|
71
|
+
title: string;
|
|
72
|
+
root_work_id: string;
|
|
73
|
+
state: SupervisorState;
|
|
74
|
+
reason: string;
|
|
75
|
+
next_action: SupervisorNextAction;
|
|
76
|
+
work_ids: string[];
|
|
77
|
+
ready_work: string[];
|
|
78
|
+
resumable_work: string[];
|
|
79
|
+
retryable_work: string[];
|
|
80
|
+
attention_work: string[];
|
|
81
|
+
attempts_used: number;
|
|
82
|
+
attempts_remaining: number;
|
|
83
|
+
specialists: string[];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface SupervisorRecord {
|
|
87
|
+
intent: SupervisorIntent;
|
|
88
|
+
projection: SupervisorProjection;
|
|
89
|
+
work: WorkRecord[];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface CreateSupervisorInput {
|
|
93
|
+
coordRoot: string;
|
|
94
|
+
rootWorkId: string;
|
|
95
|
+
specialists: Readonly<Record<string, WorkflowSpecialistProfile>>;
|
|
96
|
+
title?: string;
|
|
97
|
+
id?: string;
|
|
98
|
+
limits?: Partial<SupervisorLimits>;
|
|
99
|
+
automation?: Partial<SupervisorAutomationPolicy>;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function createSupervisor(input: CreateSupervisorInput): SupervisorRecord {
|
|
103
|
+
const coordRoot = resolve(input.coordRoot);
|
|
104
|
+
assertWorkId(input.rootWorkId);
|
|
105
|
+
const root = readWorkItem(coordRoot, input.rootWorkId);
|
|
106
|
+
const id = input.id ?? newSupervisorId();
|
|
107
|
+
assertSupervisorId(id);
|
|
108
|
+
const intent: SupervisorIntent = {
|
|
109
|
+
schema_version: SUPERVISOR_INTENT_SCHEMA_VERSION,
|
|
110
|
+
id,
|
|
111
|
+
title: bounded(input.title ?? root.intent.title, "supervisor title", MAX_TITLE),
|
|
112
|
+
root_work_id: input.rootWorkId,
|
|
113
|
+
specialists: normalizeWorkflowSpecialists(input.specialists),
|
|
114
|
+
limits: normalizeLimits(input.limits),
|
|
115
|
+
automation: normalizeAutomation(input.automation),
|
|
116
|
+
created_at: new Date().toISOString(),
|
|
117
|
+
};
|
|
118
|
+
const path = supervisorIntentPath(coordRoot, id);
|
|
119
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
120
|
+
chmodSync(dirname(path), 0o700);
|
|
121
|
+
writePrivateJson(path, intent);
|
|
122
|
+
return readSupervisor(coordRoot, id);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function readSupervisor(coordRoot: string, goalId: string): SupervisorRecord {
|
|
126
|
+
return readSupervisorInternal(coordRoot, goalId, false);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** @internal Runner seam; ignores the lease held by this supervisor process. */
|
|
130
|
+
export function readSupervisorIgnoringLease(coordRoot: string, goalId: string): SupervisorRecord {
|
|
131
|
+
return readSupervisorInternal(coordRoot, goalId, true);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function listSupervisors(coordRoot: string): SupervisorRecord[] {
|
|
135
|
+
const base = join(resolve(coordRoot), ".harnery", "supervisors");
|
|
136
|
+
if (!existsSync(base)) return [];
|
|
137
|
+
const records: SupervisorRecord[] = [];
|
|
138
|
+
for (const name of readdirSync(base)) {
|
|
139
|
+
if (!GOAL_ID.test(name) || !existsSync(supervisorIntentPath(coordRoot, name))) continue;
|
|
140
|
+
records.push(readSupervisor(coordRoot, name));
|
|
141
|
+
}
|
|
142
|
+
return records.sort((left, right) =>
|
|
143
|
+
right.intent.created_at.localeCompare(left.intent.created_at),
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function collectSupervisorWork(coordRoot: string, rootWorkId: string): WorkRecord[] {
|
|
148
|
+
const records = new Map<string, WorkRecord>();
|
|
149
|
+
const visiting = new Set<string>();
|
|
150
|
+
const visit = (workId: string): void => {
|
|
151
|
+
if (records.has(workId)) return;
|
|
152
|
+
if (visiting.has(workId))
|
|
153
|
+
throw new Error(`supervisor work graph contains a cycle at ${workId}`);
|
|
154
|
+
visiting.add(workId);
|
|
155
|
+
const record = readWorkItem(coordRoot, workId);
|
|
156
|
+
for (const dependency of record.intent.dependencies) visit(dependency);
|
|
157
|
+
visiting.delete(workId);
|
|
158
|
+
records.set(workId, record);
|
|
159
|
+
};
|
|
160
|
+
visit(rootWorkId);
|
|
161
|
+
return Array.from(records.values());
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function assertSupervisorId(goalId: string): void {
|
|
165
|
+
if (!GOAL_ID.test(goalId)) throw new Error(`invalid supervisor id ${JSON.stringify(goalId)}`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function newSupervisorId(): string {
|
|
169
|
+
return `goal-${new Date().toISOString().replace(/[:.]/g, "-")}-${randomBytes(3).toString("hex")}`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** @internal Runner seam; one exclusive crash-recoverable supervisor lease. */
|
|
173
|
+
export function acquireSupervisorLease(coordRoot: string, goalId: string): () => void {
|
|
174
|
+
readSupervisorIntent(coordRoot, goalId);
|
|
175
|
+
const path = join(supervisorDir(coordRoot, goalId), "lease.json");
|
|
176
|
+
const owner = {
|
|
177
|
+
pid: process.pid,
|
|
178
|
+
host: hostname(),
|
|
179
|
+
created_at: new Date().toISOString(),
|
|
180
|
+
nonce: randomUUID(),
|
|
181
|
+
};
|
|
182
|
+
const acquire = (): boolean => {
|
|
183
|
+
try {
|
|
184
|
+
const fd = openSync(path, "wx", 0o600);
|
|
185
|
+
try {
|
|
186
|
+
writeFileSync(fd, `${JSON.stringify(owner)}\n`, "utf8");
|
|
187
|
+
} finally {
|
|
188
|
+
closeSync(fd);
|
|
189
|
+
}
|
|
190
|
+
chmodSync(path, 0o600);
|
|
191
|
+
return true;
|
|
192
|
+
} catch (error) {
|
|
193
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
if (!acquire()) {
|
|
198
|
+
const existing = readLease(path);
|
|
199
|
+
if (existing && leaseIsLive(existing)) {
|
|
200
|
+
throw new Error(
|
|
201
|
+
`supervisor ${goalId} is already running under pid ${existing.pid} on ${existing.host}`,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
unlinkSync(path);
|
|
205
|
+
if (!acquire()) throw new Error(`supervisor ${goalId} lease raced with another process`);
|
|
206
|
+
}
|
|
207
|
+
return () => {
|
|
208
|
+
try {
|
|
209
|
+
const existing = readLease(path);
|
|
210
|
+
if (existing?.nonce === owner.nonce) unlinkSync(path);
|
|
211
|
+
} catch {
|
|
212
|
+
// A stale private lease is recoverable by the next explicit run.
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function readSupervisorInternal(
|
|
218
|
+
coordRootRaw: string,
|
|
219
|
+
goalId: string,
|
|
220
|
+
ignoreLease: boolean,
|
|
221
|
+
): SupervisorRecord {
|
|
222
|
+
const coordRoot = resolve(coordRootRaw);
|
|
223
|
+
const intent = readSupervisorIntent(coordRoot, goalId);
|
|
224
|
+
const work = collectSupervisorWork(coordRoot, intent.root_work_id);
|
|
225
|
+
return {
|
|
226
|
+
intent,
|
|
227
|
+
work,
|
|
228
|
+
projection: deriveProjection(coordRoot, intent, work, ignoreLease),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function deriveProjection(
|
|
233
|
+
coordRoot: string,
|
|
234
|
+
intent: SupervisorIntent,
|
|
235
|
+
work: WorkRecord[],
|
|
236
|
+
ignoreLease: boolean,
|
|
237
|
+
): SupervisorProjection {
|
|
238
|
+
const root = work.find((record) => record.intent.id === intent.root_work_id);
|
|
239
|
+
if (!root) throw new Error(`supervisor ${intent.id} root work is missing`);
|
|
240
|
+
const attemptsUsed = work.reduce((sum, record) => sum + record.projection.attempts_used, 0);
|
|
241
|
+
const readyWork = work
|
|
242
|
+
.filter((record) => record.projection.state === "ready")
|
|
243
|
+
.map((record) => record.intent.id);
|
|
244
|
+
const resumableWork = work
|
|
245
|
+
.filter(
|
|
246
|
+
(record) =>
|
|
247
|
+
record.projection.state === "awaiting_approval" &&
|
|
248
|
+
record.projection.next_action === "resume",
|
|
249
|
+
)
|
|
250
|
+
.map((record) => record.intent.id);
|
|
251
|
+
const retryableWork = work
|
|
252
|
+
.filter(
|
|
253
|
+
(record) =>
|
|
254
|
+
record.projection.state === "blocked" && record.projection.next_action === "retry",
|
|
255
|
+
)
|
|
256
|
+
.map((record) => record.intent.id);
|
|
257
|
+
const pendingApproval = work
|
|
258
|
+
.filter(
|
|
259
|
+
(record) =>
|
|
260
|
+
record.projection.state === "awaiting_approval" &&
|
|
261
|
+
record.projection.next_action === "resolve_approval",
|
|
262
|
+
)
|
|
263
|
+
.map((record) => record.intent.id);
|
|
264
|
+
const reviews = work
|
|
265
|
+
.filter((record) => record.projection.state === "in_review")
|
|
266
|
+
.map((record) => record.intent.id);
|
|
267
|
+
const cancelled = work
|
|
268
|
+
.filter((record) => record.projection.state === "cancelled")
|
|
269
|
+
.map((record) => record.intent.id);
|
|
270
|
+
const terminalBlocked = work
|
|
271
|
+
.filter(
|
|
272
|
+
(record) => record.projection.state === "blocked" && record.projection.next_action === "none",
|
|
273
|
+
)
|
|
274
|
+
.map((record) => record.intent.id);
|
|
275
|
+
const attentionWork = unique([
|
|
276
|
+
...pendingApproval,
|
|
277
|
+
...(intent.automation.accept_passing_proof ? [] : reviews),
|
|
278
|
+
...(intent.automation.resume_approved ? [] : resumableWork),
|
|
279
|
+
...(intent.automation.retry_blocked ? [] : retryableWork),
|
|
280
|
+
...cancelled,
|
|
281
|
+
...terminalBlocked,
|
|
282
|
+
]);
|
|
283
|
+
const base = {
|
|
284
|
+
id: intent.id,
|
|
285
|
+
title: intent.title,
|
|
286
|
+
root_work_id: intent.root_work_id,
|
|
287
|
+
work_ids: work.map((record) => record.intent.id),
|
|
288
|
+
ready_work: readyWork,
|
|
289
|
+
resumable_work: resumableWork,
|
|
290
|
+
retryable_work: retryableWork,
|
|
291
|
+
attention_work: attentionWork,
|
|
292
|
+
attempts_used: attemptsUsed,
|
|
293
|
+
attempts_remaining: Math.max(0, intent.limits.max_total_attempts - attemptsUsed),
|
|
294
|
+
specialists: Object.keys(intent.specialists),
|
|
295
|
+
};
|
|
296
|
+
if (!ignoreLease && supervisorLeaseIsLive(coordRoot, intent.id)) {
|
|
297
|
+
return {
|
|
298
|
+
...base,
|
|
299
|
+
state: "running",
|
|
300
|
+
reason: "the foreground supervisor holds the goal lease",
|
|
301
|
+
next_action: "wait_for_run",
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
if (root.projection.state === "succeeded") {
|
|
305
|
+
return {
|
|
306
|
+
...base,
|
|
307
|
+
state: "succeeded",
|
|
308
|
+
reason: "root work was explicitly accepted",
|
|
309
|
+
next_action: "none",
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
const resumableDispatchable = intent.automation.resume_approved ? resumableWork : [];
|
|
313
|
+
const attemptDispatchable = [
|
|
314
|
+
...readyWork,
|
|
315
|
+
...(intent.automation.retry_blocked ? retryableWork : []),
|
|
316
|
+
];
|
|
317
|
+
const dispatchable = [...resumableDispatchable, ...attemptDispatchable];
|
|
318
|
+
if (intent.automation.accept_passing_proof && reviews.length > 0) {
|
|
319
|
+
return {
|
|
320
|
+
...base,
|
|
321
|
+
state: "ready",
|
|
322
|
+
reason: `${reviews.length} passing work item${reviews.length === 1 ? "" : "s"} may be accepted by frozen policy`,
|
|
323
|
+
next_action: "run",
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
if (resumableDispatchable.length > 0) {
|
|
327
|
+
return {
|
|
328
|
+
...base,
|
|
329
|
+
state: "ready",
|
|
330
|
+
reason: `${resumableDispatchable.length} parked run${resumableDispatchable.length === 1 ? " is" : "s are"} resumable`,
|
|
331
|
+
next_action: "run",
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
if (attemptsUsed >= intent.limits.max_total_attempts && attemptDispatchable.length > 0) {
|
|
335
|
+
return {
|
|
336
|
+
...base,
|
|
337
|
+
state: "budget_exhausted",
|
|
338
|
+
reason: `goal exhausted its ${intent.limits.max_total_attempts} total attempts`,
|
|
339
|
+
next_action: "none",
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
if (dispatchable.length > 0) {
|
|
343
|
+
return {
|
|
344
|
+
...base,
|
|
345
|
+
state: "ready",
|
|
346
|
+
reason: `${dispatchable.length} work item${dispatchable.length === 1 ? " is" : "s are"} dispatchable`,
|
|
347
|
+
next_action: "run",
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
if (pendingApproval.length > 0) {
|
|
351
|
+
return {
|
|
352
|
+
...base,
|
|
353
|
+
state: "awaiting_attention",
|
|
354
|
+
reason: `${pendingApproval.length} work item${pendingApproval.length === 1 ? " needs" : "s need"} approval`,
|
|
355
|
+
next_action: "resolve_approval",
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
if (reviews.length > 0) {
|
|
359
|
+
return {
|
|
360
|
+
...base,
|
|
361
|
+
state: "awaiting_attention",
|
|
362
|
+
reason: `${reviews.length} work item${reviews.length === 1 ? " awaits" : "s await"} explicit review`,
|
|
363
|
+
next_action: "review",
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
if (resumableWork.length > 0) {
|
|
367
|
+
return {
|
|
368
|
+
...base,
|
|
369
|
+
state: "awaiting_attention",
|
|
370
|
+
reason: `${resumableWork.length} resolved approval${resumableWork.length === 1 ? " requires" : "s require"} explicit resume`,
|
|
371
|
+
next_action: "run",
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
if (retryableWork.length > 0) {
|
|
375
|
+
return {
|
|
376
|
+
...base,
|
|
377
|
+
state: "blocked",
|
|
378
|
+
reason: `${retryableWork.length} blocked work item${retryableWork.length === 1 ? " requires" : "s require"} explicit retry`,
|
|
379
|
+
next_action: "retry",
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
return {
|
|
383
|
+
...base,
|
|
384
|
+
state: "blocked",
|
|
385
|
+
reason:
|
|
386
|
+
attentionWork.length > 0
|
|
387
|
+
? `${attentionWork.length} work item${attentionWork.length === 1 ? " needs" : "s need"} intervention`
|
|
388
|
+
: "goal graph has no legal progress action",
|
|
389
|
+
next_action: "none",
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function readSupervisorIntent(coordRoot: string, goalId: string): SupervisorIntent {
|
|
394
|
+
assertSupervisorId(goalId);
|
|
395
|
+
const path = supervisorIntentPath(coordRoot, goalId);
|
|
396
|
+
if (!existsSync(path)) throw new Error(`supervisor ${goalId} does not exist`);
|
|
397
|
+
const size = statSync(path).size;
|
|
398
|
+
if (size <= 0 || size > MAX_INTENT_BYTES) {
|
|
399
|
+
throw new Error(`supervisor intent has invalid size ${size}`);
|
|
400
|
+
}
|
|
401
|
+
let intent: SupervisorIntent;
|
|
402
|
+
try {
|
|
403
|
+
intent = JSON.parse(readFileSync(path, "utf8")) as SupervisorIntent;
|
|
404
|
+
} catch (error) {
|
|
405
|
+
throw new Error(`cannot parse supervisor intent ${goalId}: ${(error as Error).message}`);
|
|
406
|
+
}
|
|
407
|
+
validateIntent(intent, goalId);
|
|
408
|
+
return intent;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function validateIntent(intent: SupervisorIntent, goalId: string): void {
|
|
412
|
+
if (
|
|
413
|
+
intent.schema_version !== SUPERVISOR_INTENT_SCHEMA_VERSION ||
|
|
414
|
+
intent.id !== goalId ||
|
|
415
|
+
!validTimestamp(intent.created_at)
|
|
416
|
+
) {
|
|
417
|
+
throw new Error(`supervisor intent ${goalId} has an unsupported or mismatched schema`);
|
|
418
|
+
}
|
|
419
|
+
bounded(intent.title, "supervisor title", MAX_TITLE);
|
|
420
|
+
assertWorkId(intent.root_work_id);
|
|
421
|
+
const specialists = normalizeWorkflowSpecialists(intent.specialists);
|
|
422
|
+
if (JSON.stringify(specialists) !== JSON.stringify(intent.specialists)) {
|
|
423
|
+
throw new Error(`supervisor intent ${goalId} specialists are not canonical`);
|
|
424
|
+
}
|
|
425
|
+
if (JSON.stringify(normalizeLimits(intent.limits)) !== JSON.stringify(intent.limits)) {
|
|
426
|
+
throw new Error(`supervisor intent ${goalId} limits are not canonical`);
|
|
427
|
+
}
|
|
428
|
+
if (
|
|
429
|
+
JSON.stringify(normalizeAutomation(intent.automation)) !== JSON.stringify(intent.automation)
|
|
430
|
+
) {
|
|
431
|
+
throw new Error(`supervisor intent ${goalId} automation policy is not canonical`);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function normalizeLimits(input: Partial<SupervisorLimits> | undefined): SupervisorLimits {
|
|
436
|
+
return {
|
|
437
|
+
max_cycles: positive(input?.max_cycles ?? 50, "supervisor max_cycles", 1_000),
|
|
438
|
+
max_runtime_ms: positive(
|
|
439
|
+
input?.max_runtime_ms ?? 4 * 60 * 60 * 1_000,
|
|
440
|
+
"supervisor max_runtime_ms",
|
|
441
|
+
7 * 24 * 60 * 60 * 1_000,
|
|
442
|
+
),
|
|
443
|
+
max_parallel_work: positive(input?.max_parallel_work ?? 1, "supervisor max_parallel_work", 20),
|
|
444
|
+
max_total_attempts: positive(
|
|
445
|
+
input?.max_total_attempts ?? 100,
|
|
446
|
+
"supervisor max_total_attempts",
|
|
447
|
+
10_000,
|
|
448
|
+
),
|
|
449
|
+
max_agents_per_work: positive(
|
|
450
|
+
input?.max_agents_per_work ?? 20,
|
|
451
|
+
"supervisor max_agents_per_work",
|
|
452
|
+
1_000,
|
|
453
|
+
),
|
|
454
|
+
agent_concurrency: positive(input?.agent_concurrency ?? 4, "supervisor agent_concurrency", 100),
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function normalizeAutomation(
|
|
459
|
+
input: Partial<SupervisorAutomationPolicy> | undefined,
|
|
460
|
+
): SupervisorAutomationPolicy {
|
|
461
|
+
return {
|
|
462
|
+
accept_passing_proof: boolean(input?.accept_passing_proof ?? false, "accept_passing_proof"),
|
|
463
|
+
resume_approved: boolean(input?.resume_approved ?? true, "resume_approved"),
|
|
464
|
+
retry_blocked: boolean(input?.retry_blocked ?? false, "retry_blocked"),
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function supervisorDir(coordRoot: string, goalId: string): string {
|
|
469
|
+
assertSupervisorId(goalId);
|
|
470
|
+
return join(resolve(coordRoot), ".harnery", "supervisors", goalId);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function supervisorIntentPath(coordRoot: string, goalId: string): string {
|
|
474
|
+
return join(supervisorDir(coordRoot, goalId), "intent.json");
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function supervisorLeaseIsLive(coordRoot: string, goalId: string): boolean {
|
|
478
|
+
const lease = readLease(join(supervisorDir(coordRoot, goalId), "lease.json"));
|
|
479
|
+
return lease ? leaseIsLive(lease) : false;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function readLease(
|
|
483
|
+
path: string,
|
|
484
|
+
): { pid: number; host: string; created_at: string; nonce?: string } | null {
|
|
485
|
+
try {
|
|
486
|
+
const value = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
|
|
487
|
+
return typeof value.pid === "number" &&
|
|
488
|
+
Number.isSafeInteger(value.pid) &&
|
|
489
|
+
value.pid > 0 &&
|
|
490
|
+
typeof value.host === "string" &&
|
|
491
|
+
typeof value.created_at === "string"
|
|
492
|
+
? {
|
|
493
|
+
pid: value.pid,
|
|
494
|
+
host: value.host,
|
|
495
|
+
created_at: value.created_at,
|
|
496
|
+
nonce: typeof value.nonce === "string" ? value.nonce : undefined,
|
|
497
|
+
}
|
|
498
|
+
: null;
|
|
499
|
+
} catch {
|
|
500
|
+
return null;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function leaseIsLive(lease: { pid: number; host: string; created_at: string }): boolean {
|
|
505
|
+
if (lease.host !== hostname()) {
|
|
506
|
+
const age = Date.now() - Date.parse(lease.created_at);
|
|
507
|
+
return Number.isFinite(age) && age < FOREIGN_LEASE_STALE_MS;
|
|
508
|
+
}
|
|
509
|
+
try {
|
|
510
|
+
process.kill(lease.pid, 0);
|
|
511
|
+
return true;
|
|
512
|
+
} catch (error) {
|
|
513
|
+
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function writePrivateJson(path: string, value: unknown): void {
|
|
518
|
+
const body = `${JSON.stringify(value, null, 2)}\n`;
|
|
519
|
+
if (Buffer.byteLength(body) > MAX_INTENT_BYTES) {
|
|
520
|
+
throw new Error(`supervisor intent exceeds ${MAX_INTENT_BYTES} bytes`);
|
|
521
|
+
}
|
|
522
|
+
writeFileSync(path, body, { encoding: "utf8", flag: "wx", mode: 0o600 });
|
|
523
|
+
chmodSync(path, 0o600);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function bounded(value: unknown, field: string, max: number): string {
|
|
527
|
+
if (typeof value !== "string") throw new Error(`${field} must be a string`);
|
|
528
|
+
const normalized = value.trim();
|
|
529
|
+
if (!normalized) throw new Error(`${field} must not be empty`);
|
|
530
|
+
if (normalized.length > max) throw new Error(`${field} exceeds ${max} characters`);
|
|
531
|
+
return normalized;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function positive(value: unknown, field: string, max: number): number {
|
|
535
|
+
if (!Number.isSafeInteger(value) || (value as number) < 1 || (value as number) > max) {
|
|
536
|
+
throw new Error(`${field} must be an integer from 1 to ${max}`);
|
|
537
|
+
}
|
|
538
|
+
return value as number;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function boolean(value: unknown, field: string): boolean {
|
|
542
|
+
if (typeof value !== "boolean") throw new Error(`supervisor ${field} must be boolean`);
|
|
543
|
+
return value;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function validTimestamp(value: unknown): value is string {
|
|
547
|
+
return typeof value === "string" && value.length <= 40 && Number.isFinite(Date.parse(value));
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function unique(values: string[]): string[] {
|
|
551
|
+
return [...new Set(values)];
|
|
552
|
+
}
|
|
@@ -51,6 +51,7 @@ import {
|
|
|
51
51
|
workflowScriptDigest,
|
|
52
52
|
writeWorkflowRunManifest,
|
|
53
53
|
} from "./run-state.ts";
|
|
54
|
+
import { normalizeWorkflowSpecialists, resolveSpecialistAssignment } from "./specialists.ts";
|
|
54
55
|
import type {
|
|
55
56
|
AgentOpts,
|
|
56
57
|
EngineOpts,
|
|
@@ -175,6 +176,8 @@ async function executeWorkflow(
|
|
|
175
176
|
const log = opts.onLog ?? ((line: string) => process.stderr.write(`${line}\n`));
|
|
176
177
|
const defaultHarness: HarnessName =
|
|
177
178
|
frozen?.default_harness ?? opts.defaultHarness ?? "claude-code";
|
|
179
|
+
const specialists =
|
|
180
|
+
frozen?.specialists ?? (resumeState ? {} : normalizeWorkflowSpecialists(opts.specialists));
|
|
178
181
|
const isolation = frozen?.isolation ?? opts.isolation ?? "shared";
|
|
179
182
|
const networkAccess = frozen?.network_access ?? opts.networkAccess ?? "unknown";
|
|
180
183
|
const policy =
|
|
@@ -210,6 +213,7 @@ async function executeWorkflow(
|
|
|
210
213
|
isolation,
|
|
211
214
|
network_access: networkAccess,
|
|
212
215
|
policy: policy ? (policy as NormalizedPolicy) : undefined,
|
|
216
|
+
specialists,
|
|
213
217
|
},
|
|
214
218
|
},
|
|
215
219
|
});
|
|
@@ -415,7 +419,10 @@ async function executeWorkflow(
|
|
|
415
419
|
return decision;
|
|
416
420
|
};
|
|
417
421
|
|
|
418
|
-
const agent = async (prompt: string,
|
|
422
|
+
const agent = async (prompt: string, requestedOpts: AgentOpts = {}): Promise<unknown> => {
|
|
423
|
+
const assignment = resolveSpecialistAssignment(specialists, prompt, requestedOpts);
|
|
424
|
+
const agentOpts = assignment.opts;
|
|
425
|
+
const assignmentPrompt = assignment.prompt;
|
|
419
426
|
let reservedForDispatch = 0;
|
|
420
427
|
let spawnCountClaimed = false;
|
|
421
428
|
const harness = agentOpts.harness ?? defaultHarness;
|
|
@@ -433,6 +440,7 @@ async function executeWorkflow(
|
|
|
433
440
|
id,
|
|
434
441
|
label: proofLabel,
|
|
435
442
|
stage: currentStage || undefined,
|
|
443
|
+
specialist: agentOpts.specialist,
|
|
436
444
|
harness,
|
|
437
445
|
model: agentOpts.model,
|
|
438
446
|
status: "failed",
|
|
@@ -443,7 +451,7 @@ async function executeWorkflow(
|
|
|
443
451
|
|
|
444
452
|
// Call identity for resume: same stage + harness + model + effort + turns + schema
|
|
445
453
|
// + ORIGINAL prompt → same key. Retry-mutated prompts never enter the key.
|
|
446
|
-
const key = agentCallKey(currentStage, harness, agentOpts,
|
|
454
|
+
const key = agentCallKey(currentStage, harness, agentOpts, assignmentPrompt);
|
|
447
455
|
const cached = resumeCache.get(key);
|
|
448
456
|
if (cached) {
|
|
449
457
|
// Exact-run replay skips dispatch authorization because no dispatch
|
|
@@ -458,6 +466,7 @@ async function executeWorkflow(
|
|
|
458
466
|
label,
|
|
459
467
|
key,
|
|
460
468
|
harness,
|
|
469
|
+
specialist: agentOpts.specialist ?? null,
|
|
461
470
|
model: agentOpts.model ?? null,
|
|
462
471
|
kind: cached.kind,
|
|
463
472
|
});
|
|
@@ -478,7 +487,7 @@ async function executeWorkflow(
|
|
|
478
487
|
max_attempts: maxAttempts,
|
|
479
488
|
max_turns: agentOpts.maxTurns ?? DEFAULT_MAX_TURNS,
|
|
480
489
|
timeout_ms: agentOpts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
481
|
-
prompt_bytes: Buffer.byteLength(
|
|
490
|
+
prompt_bytes: Buffer.byteLength(assignmentPrompt),
|
|
482
491
|
isolation,
|
|
483
492
|
network_access: networkAccess,
|
|
484
493
|
current_cost_usd: round4(costUsd + reservedCostUsd),
|
|
@@ -594,12 +603,13 @@ async function executeWorkflow(
|
|
|
594
603
|
label,
|
|
595
604
|
key,
|
|
596
605
|
harness,
|
|
606
|
+
specialist: agentOpts.specialist ?? null,
|
|
597
607
|
model: agentOpts.model ?? null,
|
|
598
608
|
effort: agentOpts.effort ?? null,
|
|
599
609
|
});
|
|
600
610
|
log(`[${name}] ${currentStage || "(no stage)"} → ${id} [${harness}] ${label}`);
|
|
601
611
|
|
|
602
|
-
let attemptPrompt =
|
|
612
|
+
let attemptPrompt = assignmentPrompt;
|
|
603
613
|
let last: SpawnResult | null = null;
|
|
604
614
|
let agentCostUsd = 0;
|
|
605
615
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
@@ -671,7 +681,7 @@ async function executeWorkflow(
|
|
|
671
681
|
// Feed the validation failure back verbatim — the retry prompt carries
|
|
672
682
|
// exactly what was wrong, which is what makes bounded retry converge.
|
|
673
683
|
attemptPrompt =
|
|
674
|
-
`${
|
|
684
|
+
`${assignmentPrompt}\n\nYour previous reply failed validation:\n` +
|
|
675
685
|
`${problems.map((p) => ` - ${p}`).join("\n")}\n` +
|
|
676
686
|
`Reply with ONLY the corrected JSON object. No prose, no code fences.`;
|
|
677
687
|
}
|
|
@@ -761,6 +771,7 @@ async function executeWorkflow(
|
|
|
761
771
|
acceptance: meta.acceptance,
|
|
762
772
|
max_agents: maxAgents,
|
|
763
773
|
concurrency,
|
|
774
|
+
specialists: Object.keys(specialists),
|
|
764
775
|
policy: policy ? { name: policy.name, sha256: policyDigest(policy) } : null,
|
|
765
776
|
isolation,
|
|
766
777
|
network_access: networkAccess,
|
|
@@ -889,15 +900,17 @@ function agentCallKey(
|
|
|
889
900
|
agentOpts: AgentOpts,
|
|
890
901
|
prompt: string,
|
|
891
902
|
): string {
|
|
892
|
-
const
|
|
903
|
+
const parts: unknown[] = [
|
|
893
904
|
stage,
|
|
894
905
|
harness,
|
|
895
906
|
agentOpts.model ?? null,
|
|
896
907
|
agentOpts.effort ?? null,
|
|
897
908
|
agentOpts.maxTurns ?? DEFAULT_MAX_TURNS,
|
|
898
909
|
agentOpts.schema ?? null,
|
|
899
|
-
|
|
900
|
-
|
|
910
|
+
];
|
|
911
|
+
if (agentOpts.specialist) parts.push(agentOpts.specialist);
|
|
912
|
+
parts.push(prompt);
|
|
913
|
+
const basis = JSON.stringify(parts);
|
|
901
914
|
return createHash("sha256").update(basis).digest("hex").slice(0, 16);
|
|
902
915
|
}
|
|
903
916
|
|
|
@@ -203,6 +203,7 @@ export function buildWorkflowProof(input: BuildWorkflowProofInput): WorkflowProo
|
|
|
203
203
|
const agents = input.agents.map((agent) => ({
|
|
204
204
|
...agent,
|
|
205
205
|
label: clipped(agent.label, MAX_LABEL_CHARS),
|
|
206
|
+
specialist: clippedOptional(agent.specialist, MAX_LABEL_CHARS),
|
|
206
207
|
model: clippedOptional(agent.model, MAX_LABEL_CHARS),
|
|
207
208
|
session_id: clippedOptional(agent.session_id, MAX_REF_CHARS),
|
|
208
209
|
error: clippedOptional(agent.error, MAX_SUMMARY_CHARS),
|