ennodia 0.1.0-rc.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/CONTRIBUTING.md +21 -0
- package/LICENSE +21 -0
- package/README.md +99 -0
- package/bench/README.md +32 -0
- package/bench/bug-recall/run.ts +331 -0
- package/bench/bug-recall/scorer.ts +424 -0
- package/bench/fixtures/bug-recall/001-missing-await/input.ts +33 -0
- package/bench/fixtures/bug-recall/001-missing-await/oracle.json +28 -0
- package/bench/fixtures/bug-recall/001-missing-await/responses/claude-code-solo.md +1 -0
- package/bench/fixtures/bug-recall/001-missing-await/responses/codex-solo.md +1 -0
- package/bench/fixtures/bug-recall/001-missing-await/responses/ennodia-parallel-compare.md +1 -0
- package/bench/fixtures/bug-recall/002-timeout-drain/input.ts +40 -0
- package/bench/fixtures/bug-recall/002-timeout-drain/oracle.json +28 -0
- package/bench/fixtures/bug-recall/002-timeout-drain/responses/claude-code-solo.md +1 -0
- package/bench/fixtures/bug-recall/002-timeout-drain/responses/codex-solo.md +1 -0
- package/bench/fixtures/bug-recall/002-timeout-drain/responses/ennodia-parallel-compare.md +1 -0
- package/bench/fixtures/bug-recall/003-schema-contract/input.ts +22 -0
- package/bench/fixtures/bug-recall/003-schema-contract/oracle.json +28 -0
- package/bench/fixtures/bug-recall/003-schema-contract/responses/claude-code-solo.md +1 -0
- package/bench/fixtures/bug-recall/003-schema-contract/responses/codex-solo.md +1 -0
- package/bench/fixtures/bug-recall/003-schema-contract/responses/ennodia-parallel-compare.md +1 -0
- package/bench/fixtures/bug-recall/004-cancel-propagation/input.ts +28 -0
- package/bench/fixtures/bug-recall/004-cancel-propagation/oracle.json +28 -0
- package/bench/fixtures/bug-recall/004-cancel-propagation/responses/claude-code-solo.md +1 -0
- package/bench/fixtures/bug-recall/004-cancel-propagation/responses/codex-solo.md +1 -0
- package/bench/fixtures/bug-recall/004-cancel-propagation/responses/ennodia-parallel-compare.md +1 -0
- package/bench/results/.gitkeep +1 -0
- package/docs/assets/agents/antigravity-color.svg +1 -0
- package/docs/assets/agents/claudecode-color.svg +1 -0
- package/docs/assets/agents/codex-color.svg +1 -0
- package/docs/assets/agents/opencode-dark.svg +1 -0
- package/docs/assets/agents/opencode-light.svg +1 -0
- package/docs/assets/icons/arrow.svg +1 -0
- package/docs/assets/icons/github.svg +1 -0
- package/docs/assets/icons/link.svg +1 -0
- package/docs/assets/logo-dark.svg +1 -0
- package/docs/assets/logo.svg +1 -0
- package/docs/assets/orchestration-pipeline.svg +74 -0
- package/docs/getting-started.md +107 -0
- package/docs/in-depth/architecture.md +53 -0
- package/docs/in-depth/releasing.md +120 -0
- package/docs/index.md +16 -0
- package/docs/reference/mcp-tools.md +104 -0
- package/package.json +87 -0
- package/src/compare.ts +777 -0
- package/src/diagnosis.ts +161 -0
- package/src/harnesses.ts +302 -0
- package/src/index.ts +52 -0
- package/src/planner.ts +106 -0
- package/src/runs.ts +777 -0
- package/src/server.ts +747 -0
- package/src/tasks.ts +602 -0
- package/src/version.ts +1 -0
package/src/runs.ts
ADDED
|
@@ -0,0 +1,777 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { CompareManager, CompareView } from "./compare";
|
|
3
|
+
import { diagnoseTasks, type TaskDiagnosis } from "./diagnosis";
|
|
4
|
+
import type {
|
|
5
|
+
DiscoverHarnessesOptions,
|
|
6
|
+
HarnessAdapter,
|
|
7
|
+
HarnessDiscovery,
|
|
8
|
+
} from "./harnesses";
|
|
9
|
+
import type { RoutePlan } from "./planner";
|
|
10
|
+
import type { TaskManager, TaskView } from "./tasks";
|
|
11
|
+
|
|
12
|
+
export type RunMode = "auto" | "single" | "parallel";
|
|
13
|
+
export type RunCompareMode = "auto" | boolean;
|
|
14
|
+
export type RunStatus =
|
|
15
|
+
| "executing"
|
|
16
|
+
| "comparing"
|
|
17
|
+
| "succeeded"
|
|
18
|
+
| "failed"
|
|
19
|
+
| "cancelled";
|
|
20
|
+
|
|
21
|
+
export type RunEvent = {
|
|
22
|
+
at: string;
|
|
23
|
+
type:
|
|
24
|
+
| "started"
|
|
25
|
+
| "task-started"
|
|
26
|
+
| "task-succeeded"
|
|
27
|
+
| "task-failed"
|
|
28
|
+
| "task-cancelled"
|
|
29
|
+
| "compare-skipped"
|
|
30
|
+
| "compare-started"
|
|
31
|
+
| "compare-succeeded"
|
|
32
|
+
| "compare-failed"
|
|
33
|
+
| "succeeded"
|
|
34
|
+
| "failed"
|
|
35
|
+
| "cancelled";
|
|
36
|
+
message?: string;
|
|
37
|
+
taskId?: string;
|
|
38
|
+
compareId?: string;
|
|
39
|
+
harnessId?: string;
|
|
40
|
+
elapsedMs?: number;
|
|
41
|
+
remainingMs?: number | null;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type RunStartInput = {
|
|
45
|
+
prompt: string;
|
|
46
|
+
harnessId?: string;
|
|
47
|
+
mode?: RunMode;
|
|
48
|
+
compare?: RunCompareMode;
|
|
49
|
+
cwd?: string;
|
|
50
|
+
model?: string;
|
|
51
|
+
timeoutMs?: number;
|
|
52
|
+
refresh?: boolean;
|
|
53
|
+
judgeHarnessId?: string;
|
|
54
|
+
judgeModel?: string;
|
|
55
|
+
synthesizerHarnessId?: string;
|
|
56
|
+
synthesizerModel?: string;
|
|
57
|
+
maxOutputChars?: number;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export type RunView = {
|
|
61
|
+
id: string;
|
|
62
|
+
status: RunStatus;
|
|
63
|
+
mode: RunMode;
|
|
64
|
+
compareMode: RunCompareMode;
|
|
65
|
+
promptPreview: string;
|
|
66
|
+
createdAt: string;
|
|
67
|
+
updatedAt: string;
|
|
68
|
+
endedAt?: string;
|
|
69
|
+
elapsedMs: number;
|
|
70
|
+
plan: RoutePlan;
|
|
71
|
+
selectedHarnessIds: string[];
|
|
72
|
+
taskIds: string[];
|
|
73
|
+
compareId?: string;
|
|
74
|
+
activeTaskId?: string;
|
|
75
|
+
remainingMs: number | null;
|
|
76
|
+
etaConfidence: "timeout-budget" | "unknown" | "complete";
|
|
77
|
+
finalAnswer?: string;
|
|
78
|
+
finalAnswerChars: number;
|
|
79
|
+
error?: string;
|
|
80
|
+
diagnosis?: TaskDiagnosis;
|
|
81
|
+
failedTaskDiagnosis?: TaskDiagnosis;
|
|
82
|
+
eventCount: number;
|
|
83
|
+
events: RunEvent[];
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export type RunViewOptions = {
|
|
87
|
+
includeEvents?: boolean;
|
|
88
|
+
maxEvents?: number;
|
|
89
|
+
maxAnswerChars?: number;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export type DiscoverHarnesses = (
|
|
93
|
+
options?: DiscoverHarnessesOptions,
|
|
94
|
+
) => Promise<HarnessDiscovery[]>;
|
|
95
|
+
|
|
96
|
+
export type FindHarnessAdapter = (id: string) => HarnessAdapter | undefined;
|
|
97
|
+
export type PlanRoute = (
|
|
98
|
+
prompt: string,
|
|
99
|
+
harnesses: HarnessDiscovery[],
|
|
100
|
+
) => RoutePlan;
|
|
101
|
+
|
|
102
|
+
export type RunManagerDependencies = {
|
|
103
|
+
taskManager: TaskManager;
|
|
104
|
+
compareManager: CompareManager;
|
|
105
|
+
discoverHarnesses: DiscoverHarnesses;
|
|
106
|
+
findHarnessAdapter: FindHarnessAdapter;
|
|
107
|
+
planRoute: PlanRoute;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export type RunManagerShutdownOptions = {
|
|
111
|
+
deadlineMs?: number;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
type InternalRun = {
|
|
115
|
+
id: string;
|
|
116
|
+
status: RunStatus;
|
|
117
|
+
mode: RunMode;
|
|
118
|
+
compareMode: RunCompareMode;
|
|
119
|
+
prompt: string;
|
|
120
|
+
plan: RoutePlan;
|
|
121
|
+
selectedHarnessIds: string[];
|
|
122
|
+
taskIds: string[];
|
|
123
|
+
compareId?: string;
|
|
124
|
+
finalAnswer?: string;
|
|
125
|
+
error?: string;
|
|
126
|
+
diagnosis?: TaskDiagnosis;
|
|
127
|
+
failedTaskDiagnosis?: TaskDiagnosis;
|
|
128
|
+
createdAtMs: number;
|
|
129
|
+
updatedAtMs: number;
|
|
130
|
+
endedAtMs?: number;
|
|
131
|
+
events: RunEvent[];
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
type EtaSnapshot = {
|
|
135
|
+
activeTaskId?: string;
|
|
136
|
+
remainingMs: number | null;
|
|
137
|
+
etaConfidence: "timeout-budget" | "unknown" | "complete";
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const DEFAULT_MAX_EVENTS = 100;
|
|
141
|
+
const DEFAULT_MAX_ANSWER_CHARS = 80_000;
|
|
142
|
+
const DEFAULT_SHUTDOWN_DEADLINE_MS = 5_000;
|
|
143
|
+
const POLL_MS = 500;
|
|
144
|
+
|
|
145
|
+
export class RunManager {
|
|
146
|
+
private readonly runs = new Map<string, InternalRun>();
|
|
147
|
+
private shuttingDown = false;
|
|
148
|
+
private shutdownPromise?: Promise<void>;
|
|
149
|
+
|
|
150
|
+
constructor(private readonly dependencies: RunManagerDependencies) {}
|
|
151
|
+
|
|
152
|
+
async start(input: RunStartInput): Promise<RunView> {
|
|
153
|
+
if (this.shuttingDown) {
|
|
154
|
+
throw new Error("RunManager is shutting down.");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const harnesses = await this.dependencies.discoverHarnesses({
|
|
158
|
+
refresh: input.refresh,
|
|
159
|
+
});
|
|
160
|
+
const plan = this.dependencies.planRoute(input.prompt, harnesses);
|
|
161
|
+
const mode = input.mode ?? "auto";
|
|
162
|
+
const compareMode = input.compare ?? "auto";
|
|
163
|
+
const selectedHarnessIds = selectHarnessIds(input.harnessId, mode, plan);
|
|
164
|
+
|
|
165
|
+
if (selectedHarnessIds.length === 0) {
|
|
166
|
+
throw new Error("No runnable harnesses were found.");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const now = Date.now();
|
|
170
|
+
const run: InternalRun = {
|
|
171
|
+
id: randomUUID(),
|
|
172
|
+
status: "executing",
|
|
173
|
+
mode,
|
|
174
|
+
compareMode,
|
|
175
|
+
prompt: input.prompt,
|
|
176
|
+
plan,
|
|
177
|
+
selectedHarnessIds,
|
|
178
|
+
taskIds: [],
|
|
179
|
+
createdAtMs: now,
|
|
180
|
+
updatedAtMs: now,
|
|
181
|
+
events: [],
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
this.runs.set(run.id, run);
|
|
185
|
+
this.pushEvent(run, {
|
|
186
|
+
type: "started",
|
|
187
|
+
message: `Run started with ${selectedHarnessIds.length} selected harness(es).`,
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
for (const harnessId of selectedHarnessIds) {
|
|
191
|
+
this.startChildTask(run, harnessId, harnesses, input);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (run.taskIds.length === 0) {
|
|
195
|
+
this.failRun(run, "No selected harness could start a task.");
|
|
196
|
+
return this.toView(run);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
void this.run(run, input);
|
|
200
|
+
return this.toView(run);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
listViews(options: RunViewOptions = {}): RunView[] {
|
|
204
|
+
return [...this.runs.values()]
|
|
205
|
+
.sort((a, b) => b.createdAtMs - a.createdAtMs)
|
|
206
|
+
.map((run) => this.toView(run, options));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
get(id: string, options: RunViewOptions = {}): RunView | undefined {
|
|
210
|
+
const run = this.runs.get(id);
|
|
211
|
+
return run ? this.toView(run, options) : undefined;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
cancel(id: string): RunView {
|
|
215
|
+
const run = this.requireRun(id);
|
|
216
|
+
|
|
217
|
+
if (!isTerminalRun(run)) {
|
|
218
|
+
this.cancelRun(run, "Run cancelled.");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return this.toView(run);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async shutdown(options: RunManagerShutdownOptions = {}): Promise<void> {
|
|
225
|
+
this.shuttingDown = true;
|
|
226
|
+
this.shutdownPromise ??= this.performShutdown(
|
|
227
|
+
options.deadlineMs ?? DEFAULT_SHUTDOWN_DEADLINE_MS,
|
|
228
|
+
);
|
|
229
|
+
await this.shutdownPromise;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private startChildTask(
|
|
233
|
+
run: InternalRun,
|
|
234
|
+
harnessId: string,
|
|
235
|
+
harnesses: HarnessDiscovery[],
|
|
236
|
+
input: RunStartInput,
|
|
237
|
+
): void {
|
|
238
|
+
const adapter = this.dependencies.findHarnessAdapter(harnessId);
|
|
239
|
+
const discovery = harnesses.find((harness) => harness.id === harnessId);
|
|
240
|
+
|
|
241
|
+
if (!adapter || !discovery?.runnable) {
|
|
242
|
+
this.pushEvent(run, {
|
|
243
|
+
type: "task-failed",
|
|
244
|
+
harnessId,
|
|
245
|
+
message: `Harness is not runnable: ${harnessId}.`,
|
|
246
|
+
});
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
const started = this.dependencies.taskManager.start(adapter, discovery, {
|
|
252
|
+
prompt: input.prompt,
|
|
253
|
+
cwd: input.cwd,
|
|
254
|
+
model: input.model,
|
|
255
|
+
timeoutMs: input.timeoutMs,
|
|
256
|
+
}).task;
|
|
257
|
+
|
|
258
|
+
run.taskIds.push(started.id);
|
|
259
|
+
this.pushEvent(run, {
|
|
260
|
+
type: "task-started",
|
|
261
|
+
taskId: started.id,
|
|
262
|
+
harnessId,
|
|
263
|
+
message: `Task started through ${started.harnessName}.`,
|
|
264
|
+
});
|
|
265
|
+
} catch (error) {
|
|
266
|
+
this.pushEvent(run, {
|
|
267
|
+
type: "task-failed",
|
|
268
|
+
harnessId,
|
|
269
|
+
message: errorMessage(error),
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private async waitForChildTasks(run: InternalRun): Promise<TaskView[]> {
|
|
275
|
+
const promises = run.taskIds.map(async (taskId) => {
|
|
276
|
+
const task = await this.dependencies.taskManager.waitForTerminal(taskId);
|
|
277
|
+
if (task && run.status !== "cancelled") {
|
|
278
|
+
this.pushEvent(run, eventFromTask(task));
|
|
279
|
+
}
|
|
280
|
+
return task;
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const tasks = await Promise.all(promises);
|
|
284
|
+
return tasks.filter(isTaskView);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private async run(run: InternalRun, input: RunStartInput): Promise<void> {
|
|
288
|
+
try {
|
|
289
|
+
const terminalTasks = await this.waitForChildTasks(run);
|
|
290
|
+
|
|
291
|
+
if (run.status === "cancelled") {
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const successfulTasks = terminalTasks.filter((task) =>
|
|
296
|
+
task.status === "succeeded"
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
if (successfulTasks.length === 0) {
|
|
300
|
+
this.failRun(
|
|
301
|
+
run,
|
|
302
|
+
"All child tasks failed or were cancelled.",
|
|
303
|
+
safeDiagnoseTasks(terminalTasks),
|
|
304
|
+
);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
run.failedTaskDiagnosis = safeDiagnoseTasks(
|
|
309
|
+
terminalTasks.filter((task) => task.status !== "succeeded"),
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
const comparableTaskIds = successfulTasks
|
|
313
|
+
.filter(hasComparableOutput)
|
|
314
|
+
.map((task) => task.id);
|
|
315
|
+
|
|
316
|
+
if (this.shouldCompare(run, comparableTaskIds.length)) {
|
|
317
|
+
await this.compareSuccessfulTasks(run, input, comparableTaskIds);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (compareWasExpected(run) && comparableTaskIds.length < 2) {
|
|
322
|
+
this.pushEvent(run, {
|
|
323
|
+
type: "compare-skipped",
|
|
324
|
+
message: "Compare skipped because fewer than two tasks produced output.",
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
this.succeedRun(run, formatTaskOutputs(successfulTasks));
|
|
329
|
+
} catch (error) {
|
|
330
|
+
if (run.status !== "cancelled") {
|
|
331
|
+
this.failRun(run, errorMessage(error));
|
|
332
|
+
}
|
|
333
|
+
} finally {
|
|
334
|
+
this.touch(run);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
private async compareSuccessfulTasks(
|
|
339
|
+
run: InternalRun,
|
|
340
|
+
input: RunStartInput,
|
|
341
|
+
taskIds: string[],
|
|
342
|
+
): Promise<void> {
|
|
343
|
+
run.status = "comparing";
|
|
344
|
+
this.touch(run);
|
|
345
|
+
|
|
346
|
+
const compare = await this.dependencies.compareManager.start({
|
|
347
|
+
prompt: input.prompt,
|
|
348
|
+
taskIds,
|
|
349
|
+
judgeHarnessId: input.judgeHarnessId,
|
|
350
|
+
judgeModel: input.judgeModel,
|
|
351
|
+
synthesizerHarnessId: input.synthesizerHarnessId,
|
|
352
|
+
synthesizerModel: input.synthesizerModel,
|
|
353
|
+
cwd: input.cwd,
|
|
354
|
+
timeoutMs: input.timeoutMs,
|
|
355
|
+
maxOutputChars: input.maxOutputChars,
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
run.compareId = compare.id;
|
|
359
|
+
this.pushEvent(run, {
|
|
360
|
+
type: "compare-started",
|
|
361
|
+
compareId: compare.id,
|
|
362
|
+
message: "Compare started for successful task outputs.",
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
const result = await this.waitForCompare(run, compare.id);
|
|
366
|
+
if (isRunStatus(run, "cancelled")) {
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (result.status === "succeeded" && result.synthesis?.text.trim()) {
|
|
371
|
+
this.pushEvent(run, {
|
|
372
|
+
type: "compare-succeeded",
|
|
373
|
+
compareId: compare.id,
|
|
374
|
+
message: "Compare returned the final answer.",
|
|
375
|
+
});
|
|
376
|
+
this.succeedRun(run, result.synthesis.text.trim());
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
this.pushEvent(run, {
|
|
381
|
+
type: "compare-failed",
|
|
382
|
+
compareId: compare.id,
|
|
383
|
+
message: `Compare ended with status ${result.status}.`,
|
|
384
|
+
});
|
|
385
|
+
this.failRun(run, `Compare ended with status ${result.status}.`);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
private async waitForCompare(
|
|
389
|
+
run: InternalRun,
|
|
390
|
+
compareId: string,
|
|
391
|
+
): Promise<CompareView> {
|
|
392
|
+
while (true) {
|
|
393
|
+
const compare = this.dependencies.compareManager.get(compareId, {
|
|
394
|
+
includeCandidates: false,
|
|
395
|
+
includeEvents: true,
|
|
396
|
+
maxEvents: 25,
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
if (!compare) {
|
|
400
|
+
throw new Error(`Run child compare disappeared: ${compareId}`);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (isTerminalCompare(compare) || run.status === "cancelled") {
|
|
404
|
+
return compare;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
await sleep(POLL_MS);
|
|
408
|
+
this.touch(run);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
private shouldCompare(run: InternalRun, comparableTaskCount: number): boolean {
|
|
413
|
+
if (comparableTaskCount < 2) {
|
|
414
|
+
return false;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (run.compareMode === true) {
|
|
418
|
+
return true;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (run.compareMode === false) {
|
|
422
|
+
return false;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return run.plan.compareSuggested;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
private toView(
|
|
429
|
+
run: InternalRun,
|
|
430
|
+
options: RunViewOptions = {},
|
|
431
|
+
): RunView {
|
|
432
|
+
const now = Date.now();
|
|
433
|
+
const end = run.endedAtMs ?? now;
|
|
434
|
+
const eta = this.eta(run);
|
|
435
|
+
const maxEvents = options.maxEvents ?? DEFAULT_MAX_EVENTS;
|
|
436
|
+
const maxAnswerChars = options.maxAnswerChars ?? DEFAULT_MAX_ANSWER_CHARS;
|
|
437
|
+
const finalAnswer = run.finalAnswer;
|
|
438
|
+
|
|
439
|
+
return {
|
|
440
|
+
id: run.id,
|
|
441
|
+
status: run.status,
|
|
442
|
+
mode: run.mode,
|
|
443
|
+
compareMode: run.compareMode,
|
|
444
|
+
promptPreview: preview(run.prompt),
|
|
445
|
+
createdAt: new Date(run.createdAtMs).toISOString(),
|
|
446
|
+
updatedAt: new Date(run.updatedAtMs).toISOString(),
|
|
447
|
+
endedAt: run.endedAtMs ? new Date(run.endedAtMs).toISOString() : undefined,
|
|
448
|
+
elapsedMs: Math.max(0, end - run.createdAtMs),
|
|
449
|
+
plan: run.plan,
|
|
450
|
+
selectedHarnessIds: run.selectedHarnessIds,
|
|
451
|
+
taskIds: run.taskIds,
|
|
452
|
+
compareId: run.compareId,
|
|
453
|
+
activeTaskId: eta.activeTaskId,
|
|
454
|
+
remainingMs: eta.remainingMs,
|
|
455
|
+
etaConfidence: eta.etaConfidence,
|
|
456
|
+
finalAnswer: finalAnswer ? truncate(finalAnswer, maxAnswerChars) : undefined,
|
|
457
|
+
finalAnswerChars: finalAnswer?.length ?? 0,
|
|
458
|
+
error: run.error,
|
|
459
|
+
diagnosis: run.diagnosis,
|
|
460
|
+
failedTaskDiagnosis: run.failedTaskDiagnosis,
|
|
461
|
+
eventCount: run.events.length,
|
|
462
|
+
events: options.includeEvents === false ? [] : tailItems(run.events, maxEvents),
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
private eta(run: InternalRun): EtaSnapshot {
|
|
467
|
+
if (isTerminalRun(run)) {
|
|
468
|
+
return {
|
|
469
|
+
remainingMs: 0,
|
|
470
|
+
etaConfidence: "complete",
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (run.status === "comparing" && run.compareId) {
|
|
475
|
+
const compare = this.dependencies.compareManager.get(run.compareId, {
|
|
476
|
+
includeCandidates: false,
|
|
477
|
+
includeEvents: false,
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
return {
|
|
481
|
+
activeTaskId: compare?.activeTaskId,
|
|
482
|
+
remainingMs: compare?.remainingMs ?? null,
|
|
483
|
+
etaConfidence: compare?.etaConfidence ?? "unknown",
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const runningTasks = run.taskIds
|
|
488
|
+
.map((taskId) =>
|
|
489
|
+
this.dependencies.taskManager.get(taskId, {
|
|
490
|
+
includeOutput: false,
|
|
491
|
+
includeEvents: false,
|
|
492
|
+
})
|
|
493
|
+
)
|
|
494
|
+
.filter(isRunningTask);
|
|
495
|
+
|
|
496
|
+
if (runningTasks.length === 0) {
|
|
497
|
+
return {
|
|
498
|
+
remainingMs: null,
|
|
499
|
+
etaConfidence: "unknown",
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const slowest = runningTasks.reduce((previous, current) =>
|
|
504
|
+
(current.remainingMs ?? 0) > (previous.remainingMs ?? 0) ? current : previous
|
|
505
|
+
);
|
|
506
|
+
|
|
507
|
+
return {
|
|
508
|
+
activeTaskId: slowest.id,
|
|
509
|
+
remainingMs: slowest.remainingMs,
|
|
510
|
+
etaConfidence: slowest.etaConfidence,
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
private async performShutdown(deadlineMs: number): Promise<void> {
|
|
515
|
+
const taskIds = new Set<string>();
|
|
516
|
+
|
|
517
|
+
for (const run of this.runs.values()) {
|
|
518
|
+
if (isTerminalRun(run)) {
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
for (const taskId of run.taskIds) {
|
|
523
|
+
taskIds.add(taskId);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
if (run.compareId) {
|
|
527
|
+
const compare = this.dependencies.compareManager.get(run.compareId, {
|
|
528
|
+
includeCandidates: false,
|
|
529
|
+
includeEvents: false,
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
for (const taskId of compareTaskIds(compare)) {
|
|
533
|
+
taskIds.add(taskId);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
this.cancelRun(run, "Run cancelled by shutdown.");
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
await Promise.allSettled(
|
|
541
|
+
[...taskIds].map((taskId) =>
|
|
542
|
+
this.dependencies.taskManager.waitForTerminal(taskId, deadlineMs)
|
|
543
|
+
),
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
private cancelRun(run: InternalRun, message: string): void {
|
|
548
|
+
run.status = "cancelled";
|
|
549
|
+
run.endedAtMs = Date.now();
|
|
550
|
+
this.pushEvent(run, { type: "cancelled", message });
|
|
551
|
+
|
|
552
|
+
for (const taskId of run.taskIds) {
|
|
553
|
+
const task = this.dependencies.taskManager.get(taskId, {
|
|
554
|
+
includeOutput: false,
|
|
555
|
+
includeEvents: false,
|
|
556
|
+
});
|
|
557
|
+
if (task?.status === "running") {
|
|
558
|
+
this.dependencies.taskManager.cancel(taskId);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
if (run.compareId) {
|
|
563
|
+
this.dependencies.compareManager.cancel(run.compareId);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
private succeedRun(run: InternalRun, finalAnswer: string): void {
|
|
568
|
+
run.status = "succeeded";
|
|
569
|
+
run.finalAnswer = finalAnswer;
|
|
570
|
+
run.endedAtMs = Date.now();
|
|
571
|
+
this.pushEvent(run, {
|
|
572
|
+
type: "succeeded",
|
|
573
|
+
message: "Run completed.",
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
private failRun(
|
|
578
|
+
run: InternalRun,
|
|
579
|
+
message: string,
|
|
580
|
+
diagnosis?: TaskDiagnosis,
|
|
581
|
+
): void {
|
|
582
|
+
run.status = "failed";
|
|
583
|
+
run.error = message;
|
|
584
|
+
run.diagnosis = diagnosis;
|
|
585
|
+
run.endedAtMs = Date.now();
|
|
586
|
+
this.pushEvent(run, {
|
|
587
|
+
type: "failed",
|
|
588
|
+
message,
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
private requireRun(id: string): InternalRun {
|
|
593
|
+
const run = this.runs.get(id);
|
|
594
|
+
if (!run) {
|
|
595
|
+
throw new Error(`Unknown run: ${id}`);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
return run;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
private pushEvent(run: InternalRun, event: Omit<RunEvent, "at">): void {
|
|
602
|
+
run.events.push({
|
|
603
|
+
at: new Date().toISOString(),
|
|
604
|
+
...event,
|
|
605
|
+
});
|
|
606
|
+
this.touch(run);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
private touch(run: InternalRun): void {
|
|
610
|
+
run.updatedAtMs = Date.now();
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function selectHarnessIds(
|
|
615
|
+
harnessId: string | undefined,
|
|
616
|
+
mode: RunMode,
|
|
617
|
+
plan: RoutePlan,
|
|
618
|
+
): string[] {
|
|
619
|
+
if (harnessId) {
|
|
620
|
+
return [harnessId];
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
if (mode === "parallel") {
|
|
624
|
+
return plan.candidates;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
if (mode === "single") {
|
|
628
|
+
return plan.selected ? [plan.selected] : [];
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
if (plan.parallelSuggested) {
|
|
632
|
+
return plan.candidates;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
return plan.selected ? [plan.selected] : [];
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function eventFromTask(task: TaskView): Omit<RunEvent, "at"> {
|
|
639
|
+
if (task.status === "succeeded") {
|
|
640
|
+
return {
|
|
641
|
+
type: "task-succeeded",
|
|
642
|
+
taskId: task.id,
|
|
643
|
+
harnessId: task.harnessId,
|
|
644
|
+
message: `${task.harnessName} succeeded.`,
|
|
645
|
+
elapsedMs: task.elapsedMs,
|
|
646
|
+
remainingMs: task.remainingMs,
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
if (task.status === "cancelled") {
|
|
651
|
+
return {
|
|
652
|
+
type: "task-cancelled",
|
|
653
|
+
taskId: task.id,
|
|
654
|
+
harnessId: task.harnessId,
|
|
655
|
+
message: `${task.harnessName} was cancelled.`,
|
|
656
|
+
elapsedMs: task.elapsedMs,
|
|
657
|
+
remainingMs: task.remainingMs,
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
return {
|
|
662
|
+
type: "task-failed",
|
|
663
|
+
taskId: task.id,
|
|
664
|
+
harnessId: task.harnessId,
|
|
665
|
+
message: `${task.harnessName} ended with status ${task.status}.`,
|
|
666
|
+
elapsedMs: task.elapsedMs,
|
|
667
|
+
remainingMs: task.remainingMs,
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function hasComparableOutput(task: TaskView): boolean {
|
|
672
|
+
return `${task.stdout.trim()}\n${task.stderr.trim()}`.trim().length > 0;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function formatTaskOutputs(tasks: TaskView[]): string {
|
|
676
|
+
if (tasks.length === 1) {
|
|
677
|
+
return `${tasks[0].stdout.trim()}\n${tasks[0].stderr.trim()}`.trim();
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
return tasks
|
|
681
|
+
.map((task) =>
|
|
682
|
+
[
|
|
683
|
+
`## ${task.harnessName} (${task.id})`,
|
|
684
|
+
`${task.stdout.trim()}\n${task.stderr.trim()}`.trim() ||
|
|
685
|
+
"(no output)",
|
|
686
|
+
].join("\n\n")
|
|
687
|
+
)
|
|
688
|
+
.join("\n\n");
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function compareWasExpected(run: InternalRun): boolean {
|
|
692
|
+
return run.compareMode === true ||
|
|
693
|
+
(run.compareMode === "auto" && run.plan.compareSuggested);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function safeDiagnoseTasks(tasks: TaskView[]): TaskDiagnosis | undefined {
|
|
697
|
+
try {
|
|
698
|
+
return diagnoseTasks(tasks);
|
|
699
|
+
} catch {
|
|
700
|
+
return undefined;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function isTaskView(task: TaskView | undefined): task is TaskView {
|
|
705
|
+
return Boolean(task);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function isRunningTask(task: TaskView | undefined): task is TaskView {
|
|
709
|
+
return task?.status === "running";
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function compareTaskIds(compare: CompareView | undefined): string[] {
|
|
713
|
+
if (!compare) {
|
|
714
|
+
return [];
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
return [compare.judgeTaskId, compare.synthesizerTaskId].filter(
|
|
718
|
+
(taskId): taskId is string => Boolean(taskId),
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function isTerminalCompare(compare: CompareView): boolean {
|
|
723
|
+
return (
|
|
724
|
+
compare.status === "succeeded" ||
|
|
725
|
+
compare.status === "failed" ||
|
|
726
|
+
compare.status === "cancelled"
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function isTerminalRun(run: InternalRun): boolean {
|
|
731
|
+
return (
|
|
732
|
+
run.status === "succeeded" ||
|
|
733
|
+
run.status === "failed" ||
|
|
734
|
+
run.status === "cancelled"
|
|
735
|
+
);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function isRunStatus(run: InternalRun, status: RunStatus): boolean {
|
|
739
|
+
return run.status === status;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function truncate(text: string, maxChars: number): string {
|
|
743
|
+
if (maxChars <= 0) {
|
|
744
|
+
return "";
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
if (text.length <= maxChars) {
|
|
748
|
+
return text;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
if (maxChars <= 3) {
|
|
752
|
+
return ".".repeat(maxChars);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
return `${text.slice(0, maxChars - 3)}...`;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
function tailItems<T>(items: T[], maxItems: number): T[] {
|
|
759
|
+
if (maxItems <= 0) {
|
|
760
|
+
return [];
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
return items.slice(-maxItems);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function preview(text: string): string {
|
|
767
|
+
const clean = text.replace(/\s+/g, " ").trim();
|
|
768
|
+
return clean.length <= 160 ? clean : `${clean.slice(0, 157)}...`;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function errorMessage(error: unknown): string {
|
|
772
|
+
return error instanceof Error ? error.message : String(error);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function sleep(ms: number): Promise<void> {
|
|
776
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
777
|
+
}
|