@oh-my-pi/pi-coding-agent 13.14.2 → 13.15.3
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/CHANGELOG.md +150 -0
- package/package.json +10 -8
- package/src/autoresearch/command-initialize.md +34 -0
- package/src/autoresearch/command-resume.md +17 -0
- package/src/autoresearch/contract.ts +332 -0
- package/src/autoresearch/dashboard.ts +447 -0
- package/src/autoresearch/git.ts +243 -0
- package/src/autoresearch/helpers.ts +458 -0
- package/src/autoresearch/index.ts +693 -0
- package/src/autoresearch/prompt.md +227 -0
- package/src/autoresearch/resume-message.md +16 -0
- package/src/autoresearch/state.ts +386 -0
- package/src/autoresearch/tools/init-experiment.ts +310 -0
- package/src/autoresearch/tools/log-experiment.ts +833 -0
- package/src/autoresearch/tools/run-experiment.ts +640 -0
- package/src/autoresearch/types.ts +218 -0
- package/src/cli/args.ts +8 -2
- package/src/cli/initial-message.ts +58 -0
- package/src/config/keybindings.ts +423 -212
- package/src/config/model-registry.ts +1 -0
- package/src/config/model-resolver.ts +57 -9
- package/src/config/settings-schema.ts +38 -10
- package/src/config/settings.ts +1 -4
- package/src/export/html/template.css +43 -13
- package/src/export/html/template.generated.ts +1 -1
- package/src/export/html/template.html +1 -0
- package/src/export/html/template.js +107 -0
- package/src/extensibility/extensions/types.ts +31 -8
- package/src/internal-urls/docs-index.generated.ts +1 -1
- package/src/lsp/index.ts +1 -1
- package/src/main.ts +44 -44
- package/src/mcp/oauth-discovery.ts +1 -1
- package/src/modes/acp/acp-agent.ts +957 -0
- package/src/modes/acp/acp-event-mapper.ts +531 -0
- package/src/modes/acp/acp-mode.ts +13 -0
- package/src/modes/acp/index.ts +2 -0
- package/src/modes/components/agent-dashboard.ts +5 -4
- package/src/modes/components/custom-editor.ts +53 -51
- package/src/modes/components/extensions/extension-dashboard.ts +2 -1
- package/src/modes/components/history-search.ts +2 -1
- package/src/modes/components/hook-editor.ts +2 -1
- package/src/modes/components/hook-input.ts +8 -7
- package/src/modes/components/hook-selector.ts +15 -10
- package/src/modes/components/keybinding-hints.ts +9 -9
- package/src/modes/components/login-dialog.ts +3 -3
- package/src/modes/components/mcp-add-wizard.ts +2 -1
- package/src/modes/components/model-selector.ts +14 -3
- package/src/modes/components/oauth-selector.ts +2 -1
- package/src/modes/components/session-selector.ts +2 -1
- package/src/modes/components/settings-selector.ts +2 -1
- package/src/modes/components/status-line-segment-editor.ts +2 -1
- package/src/modes/components/tree-selector.ts +3 -2
- package/src/modes/components/user-message-selector.ts +3 -8
- package/src/modes/components/user-message.ts +16 -0
- package/src/modes/controllers/extension-ui-controller.ts +89 -4
- package/src/modes/controllers/input-controller.ts +48 -29
- package/src/modes/controllers/mcp-command-controller.ts +1 -1
- package/src/modes/index.ts +1 -0
- package/src/modes/interactive-mode.ts +17 -5
- package/src/modes/print-mode.ts +1 -1
- package/src/modes/prompt-action-autocomplete.ts +7 -7
- package/src/modes/rpc/rpc-mode.ts +7 -2
- package/src/modes/rpc/rpc-types.ts +1 -0
- package/src/modes/theme/theme.ts +53 -44
- package/src/modes/types.ts +9 -2
- package/src/modes/utils/hotkeys-markdown.ts +20 -20
- package/src/modes/utils/keybinding-matchers.ts +21 -0
- package/src/modes/utils/ui-helpers.ts +1 -1
- package/src/patch/hashline.ts +139 -127
- package/src/patch/index.ts +77 -59
- package/src/patch/shared.ts +19 -11
- package/src/prompts/tools/hashline.md +43 -116
- package/src/sdk.ts +34 -17
- package/src/session/agent-session.ts +436 -86
- package/src/session/messages.ts +23 -0
- package/src/session/session-manager.ts +97 -31
- package/src/tools/ask.ts +56 -30
- package/src/tools/bash-interceptor.ts +1 -39
- package/src/tools/bash-skill-urls.ts +1 -1
- package/src/tools/browser.ts +1 -1
- package/src/tools/gemini-image.ts +1 -1
- package/src/tools/resolve.ts +1 -1
- package/src/utils/child-process.ts +88 -0
- package/src/utils/image-input.ts +11 -1
- package/src/web/search/providers/codex.ts +10 -3
|
@@ -0,0 +1,640 @@
|
|
|
1
|
+
import * as childProcess from "node:child_process";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { Text } from "@oh-my-pi/pi-tui";
|
|
5
|
+
import { formatBytes } from "@oh-my-pi/pi-utils";
|
|
6
|
+
import { Type } from "@sinclair/typebox";
|
|
7
|
+
import type { ToolDefinition } from "../../extensibility/extensions";
|
|
8
|
+
import type { Theme } from "../../modes/theme/theme";
|
|
9
|
+
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, truncateTail } from "../../session/streaming-output";
|
|
10
|
+
import { replaceTabs, shortenPath, truncateToWidth } from "../../tools/render-utils";
|
|
11
|
+
import { getAutoresearchFingerprintMismatchError } from "../contract";
|
|
12
|
+
import {
|
|
13
|
+
EXPERIMENT_MAX_BYTES,
|
|
14
|
+
EXPERIMENT_MAX_LINES,
|
|
15
|
+
formatElapsed,
|
|
16
|
+
formatNum,
|
|
17
|
+
getAutoresearchRunDirectory,
|
|
18
|
+
getNextAutoresearchRunNumber,
|
|
19
|
+
isAutoresearchShCommand,
|
|
20
|
+
killTree,
|
|
21
|
+
parseAsiLines,
|
|
22
|
+
parseMetricLines,
|
|
23
|
+
readPendingRunSummary,
|
|
24
|
+
resolveWorkDir,
|
|
25
|
+
validateWorkDir,
|
|
26
|
+
} from "../helpers";
|
|
27
|
+
import type { AutoresearchToolFactoryOptions, RunDetails, RunExperimentProgressDetails } from "../types";
|
|
28
|
+
|
|
29
|
+
const runExperimentSchema = Type.Object({
|
|
30
|
+
command: Type.String({
|
|
31
|
+
description: "Shell command to run for this experiment.",
|
|
32
|
+
}),
|
|
33
|
+
timeout_seconds: Type.Optional(
|
|
34
|
+
Type.Number({
|
|
35
|
+
description: "Timeout in seconds. Defaults to 600.",
|
|
36
|
+
}),
|
|
37
|
+
),
|
|
38
|
+
checks_timeout_seconds: Type.Optional(
|
|
39
|
+
Type.Number({
|
|
40
|
+
description: "Timeout in seconds for autoresearch.checks.sh. Defaults to 300.",
|
|
41
|
+
}),
|
|
42
|
+
),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
interface ProcessExecutionResult {
|
|
46
|
+
exitCode: number | null;
|
|
47
|
+
killed: boolean;
|
|
48
|
+
logPath: string;
|
|
49
|
+
output: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface ChecksExecutionResult {
|
|
53
|
+
code: number | null;
|
|
54
|
+
killed: boolean;
|
|
55
|
+
logPath: string;
|
|
56
|
+
output: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface ProgressSnapshot {
|
|
60
|
+
elapsed: string;
|
|
61
|
+
runDirectory: string;
|
|
62
|
+
fullOutputPath: string;
|
|
63
|
+
tailOutput: string;
|
|
64
|
+
truncation?: RunExperimentProgressDetails["truncation"];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function createRunExperimentTool(
|
|
68
|
+
options: AutoresearchToolFactoryOptions,
|
|
69
|
+
): ToolDefinition<typeof runExperimentSchema, RunDetails | RunExperimentProgressDetails> {
|
|
70
|
+
return {
|
|
71
|
+
name: "run_experiment",
|
|
72
|
+
label: "Run Experiment",
|
|
73
|
+
description:
|
|
74
|
+
"Run an experiment command with timing, output capture, structured metric parsing, durable run artifacts, and optional autoresearch.checks.sh validation.",
|
|
75
|
+
parameters: runExperimentSchema,
|
|
76
|
+
defaultInactive: true,
|
|
77
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
78
|
+
const workDirError = validateWorkDir(ctx.cwd);
|
|
79
|
+
if (workDirError) {
|
|
80
|
+
return {
|
|
81
|
+
content: [{ type: "text", text: `Error: ${workDirError}` }],
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const runtime = options.getRuntime(ctx);
|
|
86
|
+
const state = runtime.state;
|
|
87
|
+
const workDir = resolveWorkDir(ctx.cwd);
|
|
88
|
+
const checksPath = path.join(workDir, "autoresearch.checks.sh");
|
|
89
|
+
const autoresearchScriptPath = path.join(workDir, "autoresearch.sh");
|
|
90
|
+
const fingerprintError = getAutoresearchFingerprintMismatchError(state.segmentFingerprint, workDir);
|
|
91
|
+
if (fingerprintError) {
|
|
92
|
+
return {
|
|
93
|
+
content: [{ type: "text", text: `Error: ${fingerprintError}` }],
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (state.benchmarkCommand && params.command.trim() !== state.benchmarkCommand) {
|
|
98
|
+
return {
|
|
99
|
+
content: [
|
|
100
|
+
{
|
|
101
|
+
type: "text",
|
|
102
|
+
text:
|
|
103
|
+
"Error: command does not match the benchmark command recorded for this segment.\n" +
|
|
104
|
+
`Expected: ${state.benchmarkCommand}\nReceived: ${params.command}`,
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (fs.existsSync(autoresearchScriptPath) && !isAutoresearchShCommand(params.command)) {
|
|
111
|
+
return {
|
|
112
|
+
content: [
|
|
113
|
+
{
|
|
114
|
+
type: "text",
|
|
115
|
+
text:
|
|
116
|
+
`Error: autoresearch.sh exists. Run it directly instead of using a different command.\n` +
|
|
117
|
+
`Expected something like: bash autoresearch.sh\n` +
|
|
118
|
+
`Received: ${params.command}`,
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (state.maxExperiments !== null) {
|
|
125
|
+
const segmentRuns = state.results.filter(result => result.segment === state.currentSegment).length;
|
|
126
|
+
if (segmentRuns >= state.maxExperiments) {
|
|
127
|
+
return {
|
|
128
|
+
content: [
|
|
129
|
+
{
|
|
130
|
+
type: "text",
|
|
131
|
+
text: `Maximum experiments reached (${state.maxExperiments}). Re-initialize to start a new segment.`,
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const pendingRun =
|
|
139
|
+
runtime.lastRunSummary ?? (await readPendingRunSummary(workDir, collectLoggedRunNumbers(state.results)));
|
|
140
|
+
if (pendingRun) {
|
|
141
|
+
return {
|
|
142
|
+
content: [
|
|
143
|
+
{
|
|
144
|
+
type: "text",
|
|
145
|
+
text:
|
|
146
|
+
`Error: run #${pendingRun.runNumber} has not been logged yet. ` +
|
|
147
|
+
"Call log_experiment before starting another benchmark run.",
|
|
148
|
+
},
|
|
149
|
+
],
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const runNumber = getNextAutoresearchRunNumber(workDir, runtime.lastRunNumber);
|
|
154
|
+
const runDirectory = getAutoresearchRunDirectory(workDir, runNumber);
|
|
155
|
+
const benchmarkLogPath = path.join(runDirectory, "benchmark.log");
|
|
156
|
+
const checksLogPath = path.join(runDirectory, "checks.log");
|
|
157
|
+
const runJsonPath = path.join(runDirectory, "run.json");
|
|
158
|
+
await fs.promises.mkdir(runDirectory, { recursive: true });
|
|
159
|
+
runtime.lastRunChecks = null;
|
|
160
|
+
runtime.lastRunDuration = null;
|
|
161
|
+
runtime.lastRunAsi = null;
|
|
162
|
+
runtime.lastRunArtifactDir = runDirectory;
|
|
163
|
+
runtime.lastRunNumber = runNumber;
|
|
164
|
+
runtime.lastRunSummary = null;
|
|
165
|
+
await Bun.write(
|
|
166
|
+
runJsonPath,
|
|
167
|
+
JSON.stringify(
|
|
168
|
+
{
|
|
169
|
+
runNumber,
|
|
170
|
+
runDirectory,
|
|
171
|
+
benchmarkLogPath,
|
|
172
|
+
checksLogPath,
|
|
173
|
+
command: params.command,
|
|
174
|
+
startedAt: new Date().toISOString(),
|
|
175
|
+
},
|
|
176
|
+
null,
|
|
177
|
+
2,
|
|
178
|
+
),
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
runtime.runningExperiment = {
|
|
182
|
+
startedAt: Date.now(),
|
|
183
|
+
command: params.command,
|
|
184
|
+
runDirectory,
|
|
185
|
+
runNumber,
|
|
186
|
+
};
|
|
187
|
+
options.dashboard.updateWidget(ctx, runtime);
|
|
188
|
+
options.dashboard.requestRender();
|
|
189
|
+
|
|
190
|
+
const timeoutMs = Math.max(0, Math.floor((params.timeout_seconds ?? 600) * 1000));
|
|
191
|
+
const startedAt = Date.now();
|
|
192
|
+
let execution: ProcessExecutionResult;
|
|
193
|
+
try {
|
|
194
|
+
execution = await executeProcess({
|
|
195
|
+
command: ["bash", "-lc", params.command],
|
|
196
|
+
cwd: workDir,
|
|
197
|
+
logPath: benchmarkLogPath,
|
|
198
|
+
timeoutMs,
|
|
199
|
+
signal,
|
|
200
|
+
onProgress: details => {
|
|
201
|
+
onUpdate?.({
|
|
202
|
+
content: [{ type: "text", text: details.tailOutput }],
|
|
203
|
+
details: {
|
|
204
|
+
phase: "running",
|
|
205
|
+
elapsed: details.elapsed,
|
|
206
|
+
truncation: details.truncation,
|
|
207
|
+
fullOutputPath: details.fullOutputPath,
|
|
208
|
+
runDirectory: details.runDirectory,
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
} finally {
|
|
214
|
+
runtime.runningExperiment = null;
|
|
215
|
+
options.dashboard.updateWidget(ctx, runtime);
|
|
216
|
+
options.dashboard.requestRender();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const durationSeconds = (Date.now() - startedAt) / 1000;
|
|
220
|
+
runtime.lastRunDuration = durationSeconds;
|
|
221
|
+
|
|
222
|
+
const benchmarkPassed = execution.exitCode === 0 && !execution.killed;
|
|
223
|
+
let checksPass: boolean | null = null;
|
|
224
|
+
let checksTimedOut = false;
|
|
225
|
+
let checksOutput = "";
|
|
226
|
+
let checksDuration = 0;
|
|
227
|
+
let checksLogPathValue: string | undefined;
|
|
228
|
+
|
|
229
|
+
if (benchmarkPassed && fs.existsSync(checksPath)) {
|
|
230
|
+
const checksStartedAt = Date.now();
|
|
231
|
+
const checksResult = await runChecks({
|
|
232
|
+
cwd: workDir,
|
|
233
|
+
pathToChecks: checksPath,
|
|
234
|
+
logPath: checksLogPath,
|
|
235
|
+
timeoutMs: Math.max(0, Math.floor((params.checks_timeout_seconds ?? 300) * 1000)),
|
|
236
|
+
signal,
|
|
237
|
+
});
|
|
238
|
+
checksDuration = (Date.now() - checksStartedAt) / 1000;
|
|
239
|
+
checksTimedOut = checksResult.killed;
|
|
240
|
+
checksPass = checksResult.code === 0 && !checksResult.killed;
|
|
241
|
+
checksOutput = checksResult.output;
|
|
242
|
+
checksLogPathValue = checksResult.logPath;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
runtime.lastRunChecks =
|
|
246
|
+
checksPass === null
|
|
247
|
+
? null
|
|
248
|
+
: {
|
|
249
|
+
pass: checksPass,
|
|
250
|
+
output: checksOutput,
|
|
251
|
+
duration: checksDuration,
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const llmTruncation = truncateTail(execution.output, {
|
|
255
|
+
maxBytes: EXPERIMENT_MAX_BYTES,
|
|
256
|
+
maxLines: EXPERIMENT_MAX_LINES,
|
|
257
|
+
});
|
|
258
|
+
const displayTruncation = truncateTail(execution.output, {
|
|
259
|
+
maxBytes: DEFAULT_MAX_BYTES,
|
|
260
|
+
maxLines: DEFAULT_MAX_LINES,
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const parsedMetricsMap = parseMetricLines(execution.output);
|
|
264
|
+
const parsedMetrics = parsedMetricsMap.size > 0 ? Object.fromEntries(parsedMetricsMap.entries()) : null;
|
|
265
|
+
const parsedPrimary = parsedMetricsMap.get(state.metricName) ?? null;
|
|
266
|
+
const parsedAsi = parseAsiLines(execution.output);
|
|
267
|
+
runtime.lastRunAsi = parsedAsi;
|
|
268
|
+
|
|
269
|
+
const resultDetails: RunDetails = {
|
|
270
|
+
runNumber,
|
|
271
|
+
runDirectory,
|
|
272
|
+
benchmarkLogPath,
|
|
273
|
+
checksLogPath: checksLogPathValue,
|
|
274
|
+
command: params.command,
|
|
275
|
+
exitCode: execution.exitCode,
|
|
276
|
+
durationSeconds,
|
|
277
|
+
passed: benchmarkPassed && (checksPass === null || checksPass),
|
|
278
|
+
crashed: execution.exitCode !== 0 || execution.killed || checksPass === false,
|
|
279
|
+
timedOut: execution.killed,
|
|
280
|
+
tailOutput: displayTruncation.content,
|
|
281
|
+
checksPass,
|
|
282
|
+
checksTimedOut,
|
|
283
|
+
checksOutput: checksOutput.split("\n").slice(-80).join("\n"),
|
|
284
|
+
checksDuration,
|
|
285
|
+
parsedMetrics,
|
|
286
|
+
parsedPrimary,
|
|
287
|
+
parsedAsi,
|
|
288
|
+
metricName: state.metricName,
|
|
289
|
+
metricUnit: state.metricUnit,
|
|
290
|
+
truncation: llmTruncation.truncated ? llmTruncation : undefined,
|
|
291
|
+
fullOutputPath: execution.logPath,
|
|
292
|
+
};
|
|
293
|
+
runtime.lastRunSummary = {
|
|
294
|
+
checksDurationSeconds: checksDuration,
|
|
295
|
+
checksPass,
|
|
296
|
+
checksTimedOut,
|
|
297
|
+
command: params.command,
|
|
298
|
+
durationSeconds,
|
|
299
|
+
parsedAsi,
|
|
300
|
+
parsedMetrics,
|
|
301
|
+
parsedPrimary,
|
|
302
|
+
passed: resultDetails.passed,
|
|
303
|
+
runDirectory,
|
|
304
|
+
runNumber,
|
|
305
|
+
};
|
|
306
|
+
runtime.autoResumeArmed = true;
|
|
307
|
+
runtime.lastAutoResumePendingRunNumber = null;
|
|
308
|
+
options.dashboard.updateWidget(ctx, runtime);
|
|
309
|
+
options.dashboard.requestRender();
|
|
310
|
+
|
|
311
|
+
await Bun.write(
|
|
312
|
+
runJsonPath,
|
|
313
|
+
JSON.stringify(
|
|
314
|
+
{
|
|
315
|
+
runNumber,
|
|
316
|
+
runDirectory,
|
|
317
|
+
benchmarkLogPath,
|
|
318
|
+
checksLogPath: checksLogPathValue,
|
|
319
|
+
command: params.command,
|
|
320
|
+
completedAt: new Date().toISOString(),
|
|
321
|
+
durationSeconds,
|
|
322
|
+
exitCode: execution.exitCode,
|
|
323
|
+
timedOut: execution.killed,
|
|
324
|
+
checks: {
|
|
325
|
+
durationSeconds: checksDuration,
|
|
326
|
+
passed: checksPass,
|
|
327
|
+
timedOut: checksTimedOut,
|
|
328
|
+
},
|
|
329
|
+
parsedMetrics,
|
|
330
|
+
parsedPrimary,
|
|
331
|
+
parsedAsi,
|
|
332
|
+
truncation: resultDetails.truncation,
|
|
333
|
+
fullOutputPath: resultDetails.fullOutputPath,
|
|
334
|
+
},
|
|
335
|
+
null,
|
|
336
|
+
2,
|
|
337
|
+
),
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
return {
|
|
341
|
+
content: [{ type: "text", text: buildRunText(resultDetails, llmTruncation.content, state.bestMetric) }],
|
|
342
|
+
details: resultDetails,
|
|
343
|
+
};
|
|
344
|
+
},
|
|
345
|
+
renderCall(args, _options, theme): Text {
|
|
346
|
+
const commandPreview = truncateToWidth(replaceTabs(args.command), 100);
|
|
347
|
+
return new Text(
|
|
348
|
+
`${theme.fg("toolTitle", theme.bold("run_experiment"))} ${theme.fg("muted", commandPreview)}`,
|
|
349
|
+
0,
|
|
350
|
+
0,
|
|
351
|
+
);
|
|
352
|
+
},
|
|
353
|
+
renderResult(result, options, theme): Text {
|
|
354
|
+
if (isProgressDetails(result.details)) {
|
|
355
|
+
const header = theme.fg("warning", `Running ${result.details.elapsed}...`);
|
|
356
|
+
const preview = replaceTabs(result.content.find(part => part.type === "text")?.text ?? "");
|
|
357
|
+
return new Text(preview ? `${header}\n${theme.fg("dim", preview)}` : header, 0, 0);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const details = result.details;
|
|
361
|
+
if (!details || !isRunDetails(details)) {
|
|
362
|
+
return new Text(replaceTabs(result.content.find(part => part.type === "text")?.text ?? ""), 0, 0);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const statusText = renderStatus(details, theme);
|
|
366
|
+
if (!options.expanded && details.tailOutput.trim().length === 0) {
|
|
367
|
+
return new Text(statusText, 0, 0);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const preview = replaceTabs(
|
|
371
|
+
options.expanded ? details.tailOutput : details.tailOutput.split("\n").slice(-5).join("\n"),
|
|
372
|
+
);
|
|
373
|
+
const suffix =
|
|
374
|
+
options.expanded && details.truncation && details.fullOutputPath
|
|
375
|
+
? `\n${theme.fg("warning", `Full output: ${shortenPath(details.fullOutputPath)}`)}`
|
|
376
|
+
: "";
|
|
377
|
+
return new Text(preview ? `${statusText}\n${theme.fg("dim", preview)}${suffix}` : statusText, 0, 0);
|
|
378
|
+
},
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async function executeProcess(options: {
|
|
383
|
+
command: string[];
|
|
384
|
+
cwd: string;
|
|
385
|
+
logPath: string;
|
|
386
|
+
timeoutMs: number;
|
|
387
|
+
signal?: AbortSignal;
|
|
388
|
+
onProgress?(details: ProgressSnapshot): void;
|
|
389
|
+
}): Promise<ProcessExecutionResult> {
|
|
390
|
+
const { promise, resolve, reject } = Promise.withResolvers<ProcessExecutionResult>();
|
|
391
|
+
const child = childProcess.spawn(options.command[0] ?? "bash", options.command.slice(1), {
|
|
392
|
+
cwd: options.cwd,
|
|
393
|
+
detached: true,
|
|
394
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
const tailChunks: Buffer[] = [];
|
|
398
|
+
let chunksBytes = 0;
|
|
399
|
+
let killedByTimeout = false;
|
|
400
|
+
let resolved = false;
|
|
401
|
+
let writeStream: fs.WriteStream | undefined = fs.createWriteStream(options.logPath);
|
|
402
|
+
let forceKillTimeout: NodeJS.Timeout | undefined;
|
|
403
|
+
|
|
404
|
+
const closeWriteStream = (): Promise<void> => {
|
|
405
|
+
if (!writeStream) return Promise.resolve();
|
|
406
|
+
const stream = writeStream;
|
|
407
|
+
writeStream = undefined;
|
|
408
|
+
return new Promise<void>((resolveClose, rejectClose) => {
|
|
409
|
+
stream.end((error?: Error | null) => {
|
|
410
|
+
if (error) {
|
|
411
|
+
rejectClose(error);
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
resolveClose();
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
const cleanup = (): void => {
|
|
420
|
+
if (progressTimer) clearInterval(progressTimer);
|
|
421
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
422
|
+
if (forceKillTimeout) clearTimeout(forceKillTimeout);
|
|
423
|
+
options.signal?.removeEventListener("abort", abortHandler);
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
const finish = (callback: () => void): void => {
|
|
427
|
+
if (resolved) return;
|
|
428
|
+
resolved = true;
|
|
429
|
+
cleanup();
|
|
430
|
+
callback();
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
const appendChunk = (data: Buffer): void => {
|
|
434
|
+
writeStream?.write(data);
|
|
435
|
+
tailChunks.push(data);
|
|
436
|
+
chunksBytes += data.length;
|
|
437
|
+
while (chunksBytes > DEFAULT_MAX_BYTES * 2 && tailChunks.length > 1) {
|
|
438
|
+
const removed = tailChunks.shift();
|
|
439
|
+
if (removed) chunksBytes -= removed.length;
|
|
440
|
+
}
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
const snapshot = (): ProgressSnapshot => {
|
|
444
|
+
const tail = truncateTail(Buffer.concat(tailChunks).toString("utf8"), {
|
|
445
|
+
maxBytes: DEFAULT_MAX_BYTES,
|
|
446
|
+
maxLines: DEFAULT_MAX_LINES,
|
|
447
|
+
});
|
|
448
|
+
return {
|
|
449
|
+
elapsed: formatElapsed(Date.now() - startedAt),
|
|
450
|
+
runDirectory: path.dirname(options.logPath),
|
|
451
|
+
fullOutputPath: options.logPath,
|
|
452
|
+
tailOutput: tail.content,
|
|
453
|
+
truncation: tail.truncated ? tail : undefined,
|
|
454
|
+
};
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
const killTreeWithEscalation = (): void => {
|
|
458
|
+
if (!child.pid) return;
|
|
459
|
+
killTree(child.pid);
|
|
460
|
+
forceKillTimeout = setTimeout(() => {
|
|
461
|
+
if (child.pid) killTree(child.pid, "SIGKILL");
|
|
462
|
+
}, 1_000);
|
|
463
|
+
forceKillTimeout.unref?.();
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
const startedAt = Date.now();
|
|
467
|
+
const progressTimer = options.onProgress
|
|
468
|
+
? setInterval(() => {
|
|
469
|
+
options.onProgress?.(snapshot());
|
|
470
|
+
}, 1000)
|
|
471
|
+
: undefined;
|
|
472
|
+
const timeoutHandle =
|
|
473
|
+
options.timeoutMs > 0
|
|
474
|
+
? setTimeout(() => {
|
|
475
|
+
killedByTimeout = true;
|
|
476
|
+
killTreeWithEscalation();
|
|
477
|
+
}, options.timeoutMs)
|
|
478
|
+
: undefined;
|
|
479
|
+
|
|
480
|
+
const abortHandler = (): void => {
|
|
481
|
+
killTreeWithEscalation();
|
|
482
|
+
};
|
|
483
|
+
if (options.signal?.aborted) {
|
|
484
|
+
abortHandler();
|
|
485
|
+
} else {
|
|
486
|
+
options.signal?.addEventListener("abort", abortHandler, { once: true });
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
child.stdout?.on("data", data => {
|
|
490
|
+
appendChunk(data);
|
|
491
|
+
});
|
|
492
|
+
child.stderr?.on("data", data => {
|
|
493
|
+
appendChunk(data);
|
|
494
|
+
});
|
|
495
|
+
child.on("error", error => {
|
|
496
|
+
void closeWriteStream().finally(() => {
|
|
497
|
+
finish(() => reject(error));
|
|
498
|
+
});
|
|
499
|
+
});
|
|
500
|
+
child.on("close", async code => {
|
|
501
|
+
try {
|
|
502
|
+
await closeWriteStream();
|
|
503
|
+
if (options.signal?.aborted) {
|
|
504
|
+
finish(() => reject(new Error("aborted")));
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
const output = await fs.promises.readFile(options.logPath, "utf8");
|
|
508
|
+
finish(() =>
|
|
509
|
+
resolve({
|
|
510
|
+
exitCode: code,
|
|
511
|
+
killed: killedByTimeout,
|
|
512
|
+
logPath: options.logPath,
|
|
513
|
+
output,
|
|
514
|
+
}),
|
|
515
|
+
);
|
|
516
|
+
} catch (error) {
|
|
517
|
+
finish(() => reject(error));
|
|
518
|
+
}
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
return promise;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
async function runChecks(options: {
|
|
525
|
+
cwd: string;
|
|
526
|
+
pathToChecks: string;
|
|
527
|
+
logPath: string;
|
|
528
|
+
timeoutMs: number;
|
|
529
|
+
signal?: AbortSignal;
|
|
530
|
+
}): Promise<ChecksExecutionResult> {
|
|
531
|
+
const result = await executeProcess({
|
|
532
|
+
command: ["bash", options.pathToChecks],
|
|
533
|
+
cwd: options.cwd,
|
|
534
|
+
logPath: options.logPath,
|
|
535
|
+
timeoutMs: options.timeoutMs,
|
|
536
|
+
signal: options.signal,
|
|
537
|
+
});
|
|
538
|
+
return {
|
|
539
|
+
code: result.exitCode,
|
|
540
|
+
killed: result.killed,
|
|
541
|
+
logPath: result.logPath,
|
|
542
|
+
output: result.output.trim(),
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function buildRunText(details: RunDetails, outputPreview: string, bestMetric: number | null): string {
|
|
547
|
+
const lines: string[] = [];
|
|
548
|
+
lines.push(`Run directory: ${details.runDirectory}`);
|
|
549
|
+
if (details.timedOut) {
|
|
550
|
+
lines.push(`TIMEOUT after ${details.durationSeconds.toFixed(1)}s`);
|
|
551
|
+
} else if (details.exitCode !== 0) {
|
|
552
|
+
lines.push(`FAILED with exit code ${details.exitCode} in ${details.durationSeconds.toFixed(1)}s`);
|
|
553
|
+
} else {
|
|
554
|
+
lines.push(`PASSED in ${details.durationSeconds.toFixed(1)}s`);
|
|
555
|
+
}
|
|
556
|
+
if (details.checksTimedOut) {
|
|
557
|
+
lines.push(`Checks timed out after ${details.checksDuration.toFixed(1)}s`);
|
|
558
|
+
} else if (details.checksPass === false) {
|
|
559
|
+
lines.push(`Checks failed in ${details.checksDuration.toFixed(1)}s`);
|
|
560
|
+
} else if (details.checksPass === true) {
|
|
561
|
+
lines.push(`Checks passed in ${details.checksDuration.toFixed(1)}s`);
|
|
562
|
+
}
|
|
563
|
+
if (bestMetric !== null) {
|
|
564
|
+
lines.push(`Current baseline ${details.metricName}: ${formatNum(bestMetric, details.metricUnit)}`);
|
|
565
|
+
}
|
|
566
|
+
if (details.parsedPrimary !== null) {
|
|
567
|
+
lines.push(`Parsed ${details.metricName}: ${details.parsedPrimary}`);
|
|
568
|
+
lines.push(`Next log_experiment metric: ${details.parsedPrimary}`);
|
|
569
|
+
}
|
|
570
|
+
if (details.parsedMetrics) {
|
|
571
|
+
const secondaryEntries = Object.entries(details.parsedMetrics)
|
|
572
|
+
.filter(([name]) => name !== details.metricName)
|
|
573
|
+
.map(([name, value]) => [name, value] as const);
|
|
574
|
+
const secondary = secondaryEntries.map(([name, value]) => `${name}=${value}`);
|
|
575
|
+
if (secondary.length > 0) {
|
|
576
|
+
lines.push(`Parsed metrics: ${secondary.join(", ")}`);
|
|
577
|
+
lines.push(`Next log_experiment metrics: ${JSON.stringify(Object.fromEntries(secondaryEntries))}`);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
if (details.parsedAsi) {
|
|
581
|
+
lines.push(`Parsed ASI keys: ${Object.keys(details.parsedAsi).join(", ")}`);
|
|
582
|
+
}
|
|
583
|
+
lines.push("");
|
|
584
|
+
lines.push(outputPreview);
|
|
585
|
+
if (details.truncation && details.fullOutputPath) {
|
|
586
|
+
lines.push("");
|
|
587
|
+
lines.push(
|
|
588
|
+
`Output truncated (${formatBytes(EXPERIMENT_MAX_BYTES)} limit). Full output: ${details.fullOutputPath}`,
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
if (details.checksLogPath) {
|
|
592
|
+
lines.push(`Checks log: ${details.checksLogPath}`);
|
|
593
|
+
}
|
|
594
|
+
if (details.checksPass === false && details.checksOutput.length > 0) {
|
|
595
|
+
lines.push("");
|
|
596
|
+
lines.push("Checks output:");
|
|
597
|
+
lines.push(details.checksOutput);
|
|
598
|
+
}
|
|
599
|
+
return lines.join("\n").trimEnd();
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function renderStatus(details: RunDetails, theme: Theme): string {
|
|
603
|
+
if (details.timedOut) {
|
|
604
|
+
return theme.fg("error", `TIMEOUT ${details.durationSeconds.toFixed(1)}s`);
|
|
605
|
+
}
|
|
606
|
+
if (details.checksTimedOut) {
|
|
607
|
+
return theme.fg("warning", `Checks timeout ${details.checksDuration.toFixed(1)}s`);
|
|
608
|
+
}
|
|
609
|
+
if (details.checksPass === false) {
|
|
610
|
+
return theme.fg("error", `Checks failed ${details.checksDuration.toFixed(1)}s`);
|
|
611
|
+
}
|
|
612
|
+
if (details.exitCode !== 0) {
|
|
613
|
+
return theme.fg("error", `FAIL exit=${details.exitCode} ${details.durationSeconds.toFixed(1)}s`);
|
|
614
|
+
}
|
|
615
|
+
const metric =
|
|
616
|
+
details.parsedPrimary !== null
|
|
617
|
+
? ` ${details.metricName}=${formatNum(details.parsedPrimary, details.metricUnit)}`
|
|
618
|
+
: "";
|
|
619
|
+
return theme.fg("success", `PASS ${details.durationSeconds.toFixed(1)}s${metric}`);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function isRunDetails(value: unknown): value is RunDetails {
|
|
623
|
+
if (typeof value !== "object" || value === null) return false;
|
|
624
|
+
return "command" in value && "durationSeconds" in value;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function isProgressDetails(value: unknown): value is RunExperimentProgressDetails {
|
|
628
|
+
if (typeof value !== "object" || value === null) return false;
|
|
629
|
+
return "phase" in value && value.phase === "running";
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function collectLoggedRunNumbers(results: Array<{ runNumber: number | null }>): Set<number> {
|
|
633
|
+
const runNumbers = new Set<number>();
|
|
634
|
+
for (const result of results) {
|
|
635
|
+
if (result.runNumber !== null) {
|
|
636
|
+
runNumbers.add(result.runNumber);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
return runNumbers;
|
|
640
|
+
}
|