@tea-agent/loop-agent 0.8.0 → 0.9.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/AGENTS.md +2 -0
- package/CHANGELOG.md +22 -3
- package/dist/application/dag/args.js +9 -2
- package/dist/executors/shell-executor.js +74 -8
- package/dist/shared/reference-context.js +48 -22
- package/dist/task/config-types.js +1 -1
- package/dist/task/runtime.js +1 -1
- package/dist/worker/loop-agent/loop-agent-client.js +51 -10
- package/dist/worker/observe/routes.js +78 -20
- package/dist/worker/observe/server.js +8 -6
- package/dist/worker/observe/static/app.js +628 -142
- package/dist/worker/observe/static/index.html +68 -43
- package/dist/worker/observe/static/styles.css +478 -602
- package/docs/README.md +4 -0
- package/docs/design/README.md +2 -2
- package/docs/exec-plans/active/README.md +2 -2
- package/docs/exec-plans/completed/README.md +8 -0
- package/docs/reports/README.md +3 -0
- package/package.json +1 -1
- package/scripts/check-task-pool-root.sh +1 -1
- package/skills/loop-agent/references/verification-and-failure-handling.md +8 -3
package/AGENTS.md
CHANGED
|
@@ -24,6 +24,8 @@
|
|
|
24
24
|
- Shell 搜索优先 `rg`,按名找文件优先 `fd`;脚本确为 Bash 脚本时使用 Git Bash 或已配置的兼容 Bash,不要求 Windows 环境适配 POSIX 路径。
|
|
25
25
|
- 用 loop-agent 迭代本仓库时,控制器必须来自已发布的 npm 安装包;首次安装或有意升级可用 `@tea-agent/loop-agent@latest`,但一次自举任务启动后不要中途升级控制器,并记录 `npm list -g @tea-agent/loop-agent --depth=0` 显示的实际版本。不要用当前工作区的 `npm link` 或 `npm run dev` 控制可能改动 CLI、DAG runtime、executor、package metadata 或 build output 的任务。
|
|
26
26
|
- 反复出现的约束要固化为文档、脚本、检查项、测试或模板。
|
|
27
|
+
- 除非用户明确要求,不要自行引入外部 SDD、spec-first、brainstorming 等方法论的强制设计文档、审批门或专用目录;本仓库的工作流程以本文件和 `docs/` 中的治理规则为准。
|
|
28
|
+
- 对非微小的实现或修复,`exec-plan` 只负责记录 Contract、进度和验证证据,不能替代 Agent DAG。除非用户明确要求 one-shot,或在计划中记录了适用的 escape hatch 与理由,否则在改动实现文件前必须完成 `new-task`、结构化 `allowedPaths` / `forbiddenPaths`、`dag run-task` 和 `dag validate`;随后按已校验的 DAG 执行并审查 writer `writeSet`。
|
|
27
29
|
- 禁止占位实现;除非 contract 明确约定为脚手架且标出后续闭环。
|
|
28
30
|
|
|
29
31
|
## 开始顺序
|
package/CHANGELOG.md
CHANGED
|
@@ -2,9 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
这里记录每个版本对使用者有影响的变化。命令名和配置字段保持原样,其余内容尽量用日常语言说明。
|
|
4
4
|
|
|
5
|
-
## [Unreleased]
|
|
6
|
-
|
|
7
|
-
-
|
|
5
|
+
## [Unreleased]
|
|
6
|
+
|
|
7
|
+
## [0.9.0] - 2026-07-12
|
|
8
|
+
|
|
9
|
+
### 修复
|
|
10
|
+
|
|
11
|
+
- 修复 Observe 总览误把已结束、但仍保留未执行节点的 DAG 显示为“活跃 DAG”的问题。
|
|
12
|
+
- DAG 详情页中的节点输出、错误输出、依赖图和执行过程会在轮询更新时保留当前查看位置,不再把页面拉回默认位置。
|
|
13
|
+
- DAG 和 Worker 运行详情完成首次加载后,会在运行结束时停止自动刷新;页面切到后台时会降低刷新频率,减少不必要的本机资源占用。
|
|
14
|
+
- 新建 Task 默认改为串行执行验证,避免治理检查、类型检查和测试同时抢占本机资源;需要并行时仍可显式配置。
|
|
15
|
+
- `run-dag` 和 `dag run-task --execute` 会拒绝无效的 `--max-concurrent`,避免异常参数导致同一批节点同时启动。
|
|
16
|
+
- Worker batch 的 SSE 事件流在没有新增内容时不再重复读取历史事件,并且会等上一轮完成后再继续轮询。
|
|
17
|
+
- Observe 看板在一个刷新周期内会复用同一份运行快照;同时打开总览、批次、Task 或 DAG 详情时,不会为每个请求重复扫描运行记录。
|
|
18
|
+
- DAG shell 命令和 Worker 调用遇到大量输出时,只在内存中保留最近一段内容并标明截断;完整 stdout/stderr 仍会写入运行产物,避免长日志拖慢本机。
|
|
19
|
+
- 建立 reference index 时会跳过依赖、构建产物、虚拟环境和版本控制目录,并在遍历规模过大时提前停止,减少误扫大型仓库。
|
|
20
|
+
|
|
21
|
+
### 修改
|
|
22
|
+
|
|
23
|
+
- Observe 看板改为暖白运行控制台:总览、批次、Task、DAG 和异常页面使用统一的信息层级;DAG 详情中的依赖图和节点表各自完整展示,选择节点后可从右侧检查器查看输出和执行过程。
|
|
24
|
+
- 统一整理运行看板的总览、批次详情、运行详情、DAG 详情和异常 Task 页面:先展示当前状态和风险,再呈现运行记录、输出与产物,桌面端查看信息更集中。
|
|
25
|
+
- 最近 DAG 运行改为按时间排列的状态时间线,标题过长时不会再挤压状态;DAG 详情仍可从每条记录直接进入。
|
|
26
|
+
- 优化节点表格和宽屏布局:节点 ID、模型、状态、耗时和备注更容易扫读;节点输出支持 Markdown 阅读,完成节点在依赖图中以绿色状态提示。
|
|
8
27
|
|
|
9
28
|
## [0.8.0] - 2026-07-11
|
|
10
29
|
|
|
@@ -7,6 +7,13 @@ const LIFECYCLE_FILTERS = new Set([
|
|
|
7
7
|
"completed",
|
|
8
8
|
"all",
|
|
9
9
|
]);
|
|
10
|
+
function parseMaxConcurrent(value) {
|
|
11
|
+
const parsed = Number(value);
|
|
12
|
+
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
|
|
13
|
+
throw new Error("max-concurrent must be a positive integer");
|
|
14
|
+
}
|
|
15
|
+
return parsed;
|
|
16
|
+
}
|
|
10
17
|
export function parseDagRunTaskArgs(args, defaultCwd) {
|
|
11
18
|
if (args.length === 0) {
|
|
12
19
|
throw new Error("usage: dag run-task <task-id> [--output <path>] [--profile auto|minimal|standard|reviewed|supervised] [--strict-models] [--no-cursor] [--execute] [--init-only] [--dry-run] [--cwd <dir>] [--max-concurrent N] [--run-id id] [--canvas-path <abs-path> | --canvas <name> [--canvases-dir <dir>]]");
|
|
@@ -68,7 +75,7 @@ export function parseDagRunTaskArgs(args, defaultCwd) {
|
|
|
68
75
|
continue;
|
|
69
76
|
}
|
|
70
77
|
if (arg === "--max-concurrent") {
|
|
71
|
-
maxConcurrent =
|
|
78
|
+
maxConcurrent = parseMaxConcurrent(args[++i]);
|
|
72
79
|
continue;
|
|
73
80
|
}
|
|
74
81
|
if (arg === "--run-id") {
|
|
@@ -276,7 +283,7 @@ export function parseRunDagArgs(args, defaultCwd) {
|
|
|
276
283
|
dryRun = true;
|
|
277
284
|
}
|
|
278
285
|
else if (arg === "--max-concurrent") {
|
|
279
|
-
maxConcurrent =
|
|
286
|
+
maxConcurrent = parseMaxConcurrent(args[++i]);
|
|
280
287
|
}
|
|
281
288
|
else if (arg === "--run-id") {
|
|
282
289
|
runId = args[++i];
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { existsSync } from "node:fs";
|
|
2
|
+
import { appendFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { writeDagNodeTextArtifact } from "../infrastructure/harness/artifact-store.js";
|
|
5
5
|
import { truncateOutput } from "./cursor-executor.js";
|
|
@@ -11,6 +11,7 @@ const SUMMARY_STDOUT_MAX = 4_000;
|
|
|
11
11
|
const SUMMARY_STDERR_MAX = 2_000;
|
|
12
12
|
const NODE_STDOUT_MAX = 12_000;
|
|
13
13
|
const NODE_STDERR_MAX = 8_000;
|
|
14
|
+
const MAX_BUFFERED_OUTPUT_BYTES = 64 * 1024;
|
|
14
15
|
function resolveBashExecutable() {
|
|
15
16
|
const envCandidate = process.env.LOOP_AGENT_BASH ?? process.env.GIT_BASH;
|
|
16
17
|
if (envCandidate && existsSync(envCandidate))
|
|
@@ -85,8 +86,8 @@ export async function executeShellCommand(input) {
|
|
|
85
86
|
env: buildShellProcessEnv(input.envAllowlist, injectedEnv),
|
|
86
87
|
stdio: ["ignore", "pipe", "pipe"],
|
|
87
88
|
});
|
|
88
|
-
let stdout =
|
|
89
|
-
let stderr =
|
|
89
|
+
let stdout = createBoundedOutput();
|
|
90
|
+
let stderr = createBoundedOutput();
|
|
90
91
|
let timedOut = false;
|
|
91
92
|
let settled = false;
|
|
92
93
|
let timeoutHandle;
|
|
@@ -103,22 +104,36 @@ export async function executeShellCommand(input) {
|
|
|
103
104
|
command: input.command,
|
|
104
105
|
cwd: input.cwd,
|
|
105
106
|
durationMs: Date.now() - startedAt,
|
|
106
|
-
stdout,
|
|
107
|
-
|
|
107
|
+
stdout: formatBoundedOutput(stdout),
|
|
108
|
+
stdoutBytes: stdout.bytes,
|
|
109
|
+
stdoutTruncated: stdout.truncated,
|
|
110
|
+
stderr: formatBoundedOutput(stderr),
|
|
111
|
+
stderrBytes: stderr.bytes,
|
|
112
|
+
stderrTruncated: stderr.truncated,
|
|
113
|
+
...(input.outputArtifacts
|
|
114
|
+
? {
|
|
115
|
+
stdoutArtifactPath: input.outputArtifacts.stdoutPath,
|
|
116
|
+
stderrArtifactPath: input.outputArtifacts.stderrPath,
|
|
117
|
+
}
|
|
118
|
+
: {}),
|
|
108
119
|
timedOut,
|
|
109
120
|
...result,
|
|
110
121
|
});
|
|
111
122
|
}
|
|
112
123
|
child.stdout.setEncoding("utf-8");
|
|
113
124
|
child.stderr.setEncoding("utf-8");
|
|
125
|
+
initializeOutputArtifacts(input.outputArtifacts);
|
|
114
126
|
child.stdout.on("data", (chunk) => {
|
|
115
|
-
stdout
|
|
127
|
+
stdout = appendBoundedOutput(stdout, chunk);
|
|
128
|
+
appendOutputArtifact(input.outputArtifacts?.stdoutPath, chunk);
|
|
116
129
|
});
|
|
117
130
|
child.stderr.on("data", (chunk) => {
|
|
118
|
-
stderr
|
|
131
|
+
stderr = appendBoundedOutput(stderr, chunk);
|
|
132
|
+
appendOutputArtifact(input.outputArtifacts?.stderrPath, chunk);
|
|
119
133
|
});
|
|
120
134
|
child.on("error", (error) => {
|
|
121
|
-
stderr
|
|
135
|
+
stderr = appendBoundedOutput(stderr, `${stderr.text ? "\n" : ""}${error.message}`);
|
|
136
|
+
appendOutputArtifact(input.outputArtifacts?.stderrPath, error.message);
|
|
122
137
|
finish({
|
|
123
138
|
exitCode: null,
|
|
124
139
|
failureCategory: "spawn-error",
|
|
@@ -149,6 +164,46 @@ export async function executeShellCommand(input) {
|
|
|
149
164
|
}
|
|
150
165
|
});
|
|
151
166
|
}
|
|
167
|
+
function createBoundedOutput() {
|
|
168
|
+
return { bytes: 0, text: "", truncated: false };
|
|
169
|
+
}
|
|
170
|
+
function appendBoundedOutput(current, chunk) {
|
|
171
|
+
const bytes = current.bytes + Buffer.byteLength(chunk);
|
|
172
|
+
const combined = current.text + chunk;
|
|
173
|
+
if (Buffer.byteLength(combined) <= MAX_BUFFERED_OUTPUT_BYTES) {
|
|
174
|
+
return { bytes, text: combined, truncated: current.truncated };
|
|
175
|
+
}
|
|
176
|
+
const tail = Buffer.from(combined)
|
|
177
|
+
.subarray(-MAX_BUFFERED_OUTPUT_BYTES)
|
|
178
|
+
.toString("utf-8")
|
|
179
|
+
.replace(/^\uFFFD/, "");
|
|
180
|
+
return { bytes, text: tail, truncated: true };
|
|
181
|
+
}
|
|
182
|
+
function formatBoundedOutput(output) {
|
|
183
|
+
return output.truncated ? `${output.text}\n...[truncated]` : output.text;
|
|
184
|
+
}
|
|
185
|
+
function initializeOutputArtifacts(paths) {
|
|
186
|
+
if (!paths)
|
|
187
|
+
return;
|
|
188
|
+
try {
|
|
189
|
+
mkdirSync(path.dirname(paths.stdoutPath), { recursive: true });
|
|
190
|
+
writeFileSync(paths.stdoutPath, "", "utf-8");
|
|
191
|
+
writeFileSync(paths.stderrPath, "", "utf-8");
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
// Artifact persistence is best-effort; the bounded result remains available.
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
function appendOutputArtifact(filePath, chunk) {
|
|
198
|
+
if (!filePath)
|
|
199
|
+
return;
|
|
200
|
+
try {
|
|
201
|
+
appendFileSync(filePath, chunk, "utf-8");
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
// Keep command execution independent from a transient artifact write failure.
|
|
205
|
+
}
|
|
206
|
+
}
|
|
152
207
|
function summarizeCommandResults(results) {
|
|
153
208
|
const stdout = results
|
|
154
209
|
.map((result) => [`$ ${result.command}`, result.stdout.trim()].filter(Boolean).join("\n"))
|
|
@@ -195,11 +250,17 @@ export function buildShellResultSummaryMarkdown(input) {
|
|
|
195
250
|
lines.push("", `## Command ${index + 1}: ${result.command}`, "", "### stdout", "", "```text", stdout.text, "```");
|
|
196
251
|
if (stdout.truncated)
|
|
197
252
|
lines.push("", "_(stdout truncated)_");
|
|
253
|
+
if (result.stdoutArtifactPath) {
|
|
254
|
+
lines.push("", `Full stdout artifact: ${result.stdoutArtifactPath}`);
|
|
255
|
+
}
|
|
198
256
|
if (stderr.text.trim()) {
|
|
199
257
|
lines.push("", "### stderr", "", "```text", stderr.text, "```");
|
|
200
258
|
if (stderr.truncated)
|
|
201
259
|
lines.push("", "_(stderr truncated)_");
|
|
202
260
|
}
|
|
261
|
+
if (result.stderrArtifactPath) {
|
|
262
|
+
lines.push("", `Full stderr artifact: ${result.stderrArtifactPath}`);
|
|
263
|
+
}
|
|
203
264
|
}
|
|
204
265
|
return `${lines.join("\n")}\n`;
|
|
205
266
|
}
|
|
@@ -235,12 +296,17 @@ export async function executeDagShellNode(input, meta) {
|
|
|
235
296
|
beforeStatus = undefined;
|
|
236
297
|
}
|
|
237
298
|
for (const command of commands) {
|
|
299
|
+
const commandNumber = results.length + 1;
|
|
238
300
|
const result = await executeShellCommand({
|
|
239
301
|
command,
|
|
240
302
|
cwd,
|
|
241
303
|
timeoutMs,
|
|
242
304
|
envAllowlist: shell.envAllowlist,
|
|
243
305
|
dagRunMeta: { runDir: meta.runDir, runId: meta.runId },
|
|
306
|
+
outputArtifacts: {
|
|
307
|
+
stdoutPath: path.join(meta.runDir, input.task.id, "commands", `${commandNumber}.stdout.txt`),
|
|
308
|
+
stderrPath: path.join(meta.runDir, input.task.id, "commands", `${commandNumber}.stderr.txt`),
|
|
309
|
+
},
|
|
244
310
|
});
|
|
245
311
|
results.push(result);
|
|
246
312
|
if (!result.ok)
|
|
@@ -7,6 +7,17 @@ import path from 'node:path';
|
|
|
7
7
|
const DEFAULT_MAX_FILES_PER_REPO = 24;
|
|
8
8
|
const DEFAULT_MAX_TOTAL_FILES = 48;
|
|
9
9
|
const DEFAULT_MAX_FILE_BYTES = 512_000;
|
|
10
|
+
const MAX_REFERENCE_ENTRIES_PER_REPO = 1_024;
|
|
11
|
+
const SKIPPED_REFERENCE_DIRECTORIES = new Set([
|
|
12
|
+
'.git',
|
|
13
|
+
'.hg',
|
|
14
|
+
'.svn',
|
|
15
|
+
'.venv',
|
|
16
|
+
'build',
|
|
17
|
+
'dist',
|
|
18
|
+
'node_modules',
|
|
19
|
+
'target',
|
|
20
|
+
]);
|
|
10
21
|
export function isFeatureStudyTask(taskConfig) {
|
|
11
22
|
return taskConfig.taskKind === 'feature-study';
|
|
12
23
|
}
|
|
@@ -102,8 +113,8 @@ function uniqueKeepOrder(items) {
|
|
|
102
113
|
return result;
|
|
103
114
|
}
|
|
104
115
|
async function collectRepoFiles(root, focusGlobs, maxFiles) {
|
|
105
|
-
const candidates = await listFilesRecursive(root);
|
|
106
|
-
const matched = candidates
|
|
116
|
+
const candidates = await listFilesRecursive(root, MAX_REFERENCE_ENTRIES_PER_REPO);
|
|
117
|
+
const matched = candidates.files
|
|
107
118
|
.map((absolutePath) => ({
|
|
108
119
|
absolutePath,
|
|
109
120
|
relativePath: path.relative(root, absolutePath).replace(/\\/g, '/'),
|
|
@@ -111,7 +122,7 @@ async function collectRepoFiles(root, focusGlobs, maxFiles) {
|
|
|
111
122
|
.filter((entry) => focusGlobs.some((pattern) => matchGlob(pattern, entry.relativePath)))
|
|
112
123
|
.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
|
|
113
124
|
const files = [];
|
|
114
|
-
let truncated =
|
|
125
|
+
let truncated = candidates.truncated;
|
|
115
126
|
for (const entry of matched) {
|
|
116
127
|
if (files.length >= maxFiles) {
|
|
117
128
|
truncated = true;
|
|
@@ -122,10 +133,6 @@ async function collectRepoFiles(root, focusGlobs, maxFiles) {
|
|
|
122
133
|
continue;
|
|
123
134
|
if (fileStat.size > DEFAULT_MAX_FILE_BYTES)
|
|
124
135
|
continue;
|
|
125
|
-
const lower = entry.relativePath.toLowerCase();
|
|
126
|
-
if (lower.includes('/target/') || lower.includes('/node_modules/') || lower.includes('/.git/')) {
|
|
127
|
-
continue;
|
|
128
|
-
}
|
|
129
136
|
files.push({
|
|
130
137
|
relativePath: entry.relativePath,
|
|
131
138
|
absolutePath: entry.absolutePath,
|
|
@@ -134,21 +141,40 @@ async function collectRepoFiles(root, focusGlobs, maxFiles) {
|
|
|
134
141
|
}
|
|
135
142
|
return { files, truncated };
|
|
136
143
|
}
|
|
137
|
-
async function listFilesRecursive(dir) {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
144
|
+
async function listFilesRecursive(dir, maxEntries) {
|
|
145
|
+
const files = [];
|
|
146
|
+
let visited = 0;
|
|
147
|
+
let truncated = false;
|
|
148
|
+
const walk = async (current) => {
|
|
149
|
+
let entries;
|
|
150
|
+
try {
|
|
151
|
+
entries = await readdir(current, { withFileTypes: true });
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
for (const entry of entries) {
|
|
157
|
+
if (visited >= maxEntries) {
|
|
158
|
+
truncated = true;
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
visited += 1;
|
|
162
|
+
if (entry.isDirectory() && SKIPPED_REFERENCE_DIRECTORIES.has(entry.name.toLowerCase())) {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const target = path.join(current, entry.name);
|
|
166
|
+
if (entry.isDirectory()) {
|
|
167
|
+
await walk(target);
|
|
168
|
+
if (truncated)
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
files.push(target);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
await walk(dir);
|
|
177
|
+
return { files, truncated };
|
|
152
178
|
}
|
|
153
179
|
export function matchGlob(pattern, relativePath) {
|
|
154
180
|
const normalizedPattern = pattern.replace(/\\/g, '/');
|
|
@@ -76,7 +76,7 @@ export const taskConfigSchema = z.object({
|
|
|
76
76
|
complexity: taskComplexitySchema.optional().default("medium"),
|
|
77
77
|
/** Explicit delivery capabilities that affect writer contracts/model tier without changing risk. */
|
|
78
78
|
capabilities: z.array(taskCapabilitySchema).optional(),
|
|
79
|
-
verifyMode: verifyModeSchema.optional().default("
|
|
79
|
+
verifyMode: verifyModeSchema.optional().default("serial"),
|
|
80
80
|
verifyPreset: verifyPresetSchema.optional().default("auto"),
|
|
81
81
|
/** Task-contract commands that must be included in final DAG shell verification. */
|
|
82
82
|
verifyCommands: z.array(taskVerifyCommandSchema).optional().default([]),
|
package/dist/task/runtime.js
CHANGED
|
@@ -102,7 +102,7 @@ export async function createTask(repoRoot, taskId, title) {
|
|
|
102
102
|
complexity: 'medium',
|
|
103
103
|
dagFallbackReason: '',
|
|
104
104
|
contextProfile: 'full',
|
|
105
|
-
verifyMode: '
|
|
105
|
+
verifyMode: 'serial',
|
|
106
106
|
verifyPreset: 'auto',
|
|
107
107
|
verifyCommands: [],
|
|
108
108
|
verifyQuota: 'full',
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { appendFileSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { parseCommandJson } from "./parse-json.js";
|
|
6
6
|
export const DEFAULT_WORKER_COMMAND_TIMEOUT_MS = 120_000;
|
|
7
7
|
export const DEFAULT_HEARTBEAT_INTERVAL_MS = 15_000;
|
|
8
|
+
const MAX_BUFFERED_OUTPUT_BYTES = 64 * 1024;
|
|
8
9
|
export class LoopAgentClient {
|
|
9
10
|
loopAgentBin;
|
|
10
11
|
baseArgs;
|
|
@@ -35,7 +36,7 @@ export class LoopAgentClient {
|
|
|
35
36
|
const stdoutPath = path.join(artifactDir, "stdout.txt");
|
|
36
37
|
const stderrPath = path.join(artifactDir, "stderr.txt");
|
|
37
38
|
const resultPath = path.join(artifactDir, "result.json");
|
|
38
|
-
const { stdout, stderr, exitCode, timedOut } = await spawnCommand({
|
|
39
|
+
const { stdout, stdoutBytes, stdoutTruncated, stderr, stderrBytes, stderrTruncated, exitCode, timedOut, } = await spawnCommand({
|
|
39
40
|
command,
|
|
40
41
|
args: commandArgs,
|
|
41
42
|
cwd: options.cwd,
|
|
@@ -58,7 +59,11 @@ export class LoopAgentClient {
|
|
|
58
59
|
durationMs: Date.now() - startedAt,
|
|
59
60
|
exitCode,
|
|
60
61
|
stdout,
|
|
62
|
+
stdoutBytes,
|
|
63
|
+
stdoutTruncated,
|
|
61
64
|
stderr,
|
|
65
|
+
stderrBytes,
|
|
66
|
+
stderrTruncated,
|
|
62
67
|
timedOut,
|
|
63
68
|
artifacts: {
|
|
64
69
|
dir: artifactDir,
|
|
@@ -68,7 +73,10 @@ export class LoopAgentClient {
|
|
|
68
73
|
},
|
|
69
74
|
};
|
|
70
75
|
if (options.expectJson) {
|
|
71
|
-
const
|
|
76
|
+
const jsonSource = stdoutTruncated
|
|
77
|
+
? await readFullOutputArtifact(stdoutPath, stdout)
|
|
78
|
+
: stdout;
|
|
79
|
+
const parsed = parseCommandJson(jsonSource);
|
|
72
80
|
if (parsed.ok) {
|
|
73
81
|
result.json = parsed.value;
|
|
74
82
|
}
|
|
@@ -77,8 +85,6 @@ export class LoopAgentClient {
|
|
|
77
85
|
result.parseFailure = parsed.failure;
|
|
78
86
|
}
|
|
79
87
|
}
|
|
80
|
-
await writeFile(result.artifacts.stdoutPath, stdout, "utf-8");
|
|
81
|
-
await writeFile(result.artifacts.stderrPath, stderr, "utf-8");
|
|
82
88
|
await writeFile(result.artifacts.resultPath, `${JSON.stringify(result, null, 2)}\n`, "utf-8");
|
|
83
89
|
return result;
|
|
84
90
|
}
|
|
@@ -109,8 +115,8 @@ function spawnCommand(input) {
|
|
|
109
115
|
resultPath: input.resultPath,
|
|
110
116
|
},
|
|
111
117
|
});
|
|
112
|
-
let stdout =
|
|
113
|
-
let stderr =
|
|
118
|
+
let stdout = createBoundedOutput();
|
|
119
|
+
let stderr = createBoundedOutput();
|
|
114
120
|
let timedOut = false;
|
|
115
121
|
const timeout = setTimeout(() => {
|
|
116
122
|
timedOut = true;
|
|
@@ -132,12 +138,12 @@ function spawnCommand(input) {
|
|
|
132
138
|
child.stdout.setEncoding("utf8");
|
|
133
139
|
child.stderr.setEncoding("utf8");
|
|
134
140
|
child.stdout.on("data", (chunk) => {
|
|
135
|
-
stdout
|
|
141
|
+
stdout = appendBoundedOutput(stdout, chunk);
|
|
136
142
|
appendChunkBestEffort(input.stdoutPath, chunk);
|
|
137
143
|
invokeChunkCallback(input.onStdout, chunk);
|
|
138
144
|
});
|
|
139
145
|
child.stderr.on("data", (chunk) => {
|
|
140
|
-
stderr
|
|
146
|
+
stderr = appendBoundedOutput(stderr, chunk);
|
|
141
147
|
appendChunkBestEffort(input.stderrPath, chunk);
|
|
142
148
|
invokeChunkCallback(input.onStderr, chunk);
|
|
143
149
|
});
|
|
@@ -147,10 +153,45 @@ function spawnCommand(input) {
|
|
|
147
153
|
});
|
|
148
154
|
child.on("close", (exitCode) => {
|
|
149
155
|
clearTimers();
|
|
150
|
-
resolve({
|
|
156
|
+
resolve({
|
|
157
|
+
stdout: formatBoundedOutput(stdout),
|
|
158
|
+
stdoutBytes: stdout.bytes,
|
|
159
|
+
stdoutTruncated: stdout.truncated,
|
|
160
|
+
stderr: formatBoundedOutput(stderr),
|
|
161
|
+
stderrBytes: stderr.bytes,
|
|
162
|
+
stderrTruncated: stderr.truncated,
|
|
163
|
+
exitCode,
|
|
164
|
+
timedOut,
|
|
165
|
+
});
|
|
151
166
|
});
|
|
152
167
|
});
|
|
153
168
|
}
|
|
169
|
+
function createBoundedOutput() {
|
|
170
|
+
return { bytes: 0, text: "", truncated: false };
|
|
171
|
+
}
|
|
172
|
+
function appendBoundedOutput(current, chunk) {
|
|
173
|
+
const bytes = current.bytes + Buffer.byteLength(chunk);
|
|
174
|
+
const combined = current.text + chunk;
|
|
175
|
+
if (Buffer.byteLength(combined) <= MAX_BUFFERED_OUTPUT_BYTES) {
|
|
176
|
+
return { bytes, text: combined, truncated: current.truncated };
|
|
177
|
+
}
|
|
178
|
+
const tail = Buffer.from(combined)
|
|
179
|
+
.subarray(-MAX_BUFFERED_OUTPUT_BYTES)
|
|
180
|
+
.toString("utf-8")
|
|
181
|
+
.replace(/^\uFFFD/, "");
|
|
182
|
+
return { bytes, text: tail, truncated: true };
|
|
183
|
+
}
|
|
184
|
+
function formatBoundedOutput(output) {
|
|
185
|
+
return output.truncated ? `${output.text}\n...[truncated]` : output.text;
|
|
186
|
+
}
|
|
187
|
+
async function readFullOutputArtifact(filePath, fallback) {
|
|
188
|
+
try {
|
|
189
|
+
return await readFile(filePath, "utf-8");
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
return fallback;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
154
195
|
function appendChunkBestEffort(filePath, chunk) {
|
|
155
196
|
try {
|
|
156
197
|
appendFileSync(filePath, chunk, "utf-8");
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
-
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { readFile, stat } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { parseWorkerEventLine } from "../observability/events.js";
|
|
5
5
|
import { isSafeObservabilityIdentifier } from "../observability/event-store.js";
|
|
6
6
|
import { buildGlobalSnapshot } from "../observability/read-model.js";
|
|
7
7
|
import { getTaskPoolRoot } from "../pool/run-store.js";
|
|
8
8
|
import { resolveArtifactPath, toRepoRelativeArtifactPath, } from "./paths.js";
|
|
9
|
+
export function createObserveSnapshotCache() {
|
|
10
|
+
return { expiresAt: 0 };
|
|
11
|
+
}
|
|
9
12
|
const ROUTES = [
|
|
10
13
|
{ method: "GET", pattern: /^\/api\/health$/, handler: handleHealth },
|
|
11
14
|
{ method: "GET", pattern: /^\/api\/snapshot$/, handler: handleSnapshot },
|
|
@@ -99,16 +102,38 @@ async function handleHealth(_req, res, _match, ctx) {
|
|
|
99
102
|
generatedAt: new Date().toISOString(),
|
|
100
103
|
});
|
|
101
104
|
}
|
|
105
|
+
async function getSnapshot(ctx) {
|
|
106
|
+
const now = Date.now();
|
|
107
|
+
if (ctx.snapshotCache.snapshot && now < ctx.snapshotCache.expiresAt) {
|
|
108
|
+
return ctx.snapshotCache.snapshot;
|
|
109
|
+
}
|
|
110
|
+
if (ctx.snapshotCache.inFlight) {
|
|
111
|
+
return ctx.snapshotCache.inFlight;
|
|
112
|
+
}
|
|
113
|
+
const inFlight = buildGlobalSnapshot({ repoRoot: ctx.repoRoot });
|
|
114
|
+
ctx.snapshotCache.inFlight = inFlight;
|
|
115
|
+
try {
|
|
116
|
+
const snapshot = await inFlight;
|
|
117
|
+
ctx.snapshotCache.snapshot = snapshot;
|
|
118
|
+
ctx.snapshotCache.expiresAt = Date.now() + ctx.pollIntervalMs;
|
|
119
|
+
return snapshot;
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
if (ctx.snapshotCache.inFlight === inFlight) {
|
|
123
|
+
ctx.snapshotCache.inFlight = undefined;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
102
127
|
async function handleSnapshot(_req, res, _match, ctx) {
|
|
103
|
-
const snapshot = await
|
|
128
|
+
const snapshot = await getSnapshot(ctx);
|
|
104
129
|
sendJson(res, 200, snapshot);
|
|
105
130
|
}
|
|
106
131
|
async function handleBatches(_req, res, _match, ctx) {
|
|
107
|
-
const snapshot = await
|
|
132
|
+
const snapshot = await getSnapshot(ctx);
|
|
108
133
|
sendJson(res, 200, snapshot.batches);
|
|
109
134
|
}
|
|
110
135
|
async function handleBatchById(_req, res, match, ctx) {
|
|
111
|
-
const snapshot = await
|
|
136
|
+
const snapshot = await getSnapshot(ctx);
|
|
112
137
|
const batch = snapshot.batches.find((b) => b.batchRunId === match.params.id);
|
|
113
138
|
if (!batch) {
|
|
114
139
|
sendJson(res, 404, { error: "Batch not found" });
|
|
@@ -117,11 +142,11 @@ async function handleBatchById(_req, res, match, ctx) {
|
|
|
117
142
|
sendJson(res, 200, batch);
|
|
118
143
|
}
|
|
119
144
|
async function handleTasks(_req, res, _match, ctx) {
|
|
120
|
-
const snapshot = await
|
|
145
|
+
const snapshot = await getSnapshot(ctx);
|
|
121
146
|
sendJson(res, 200, snapshot.tasks);
|
|
122
147
|
}
|
|
123
148
|
async function handleTaskById(_req, res, match, ctx) {
|
|
124
|
-
const snapshot = await
|
|
149
|
+
const snapshot = await getSnapshot(ctx);
|
|
125
150
|
const task = snapshot.tasks.find((t) => t.taskId === match.params.id);
|
|
126
151
|
if (!task) {
|
|
127
152
|
sendJson(res, 404, { error: "Task not found" });
|
|
@@ -134,7 +159,7 @@ async function handleRunById(_req, res, match, ctx) {
|
|
|
134
159
|
sendJson(res, 400, { error: "Invalid run identifier" });
|
|
135
160
|
return;
|
|
136
161
|
}
|
|
137
|
-
const snapshot = await
|
|
162
|
+
const snapshot = await getSnapshot(ctx);
|
|
138
163
|
const task = findRunByWorkerRunId(snapshot.tasks, snapshot.batches, match.params.id);
|
|
139
164
|
if (!task) {
|
|
140
165
|
sendJson(res, 404, { error: "Run not found" });
|
|
@@ -159,7 +184,7 @@ async function handleRunArtifacts(_req, res, match, ctx) {
|
|
|
159
184
|
sendJson(res, 400, { error: "Invalid run identifier" });
|
|
160
185
|
return;
|
|
161
186
|
}
|
|
162
|
-
const snapshot = await
|
|
187
|
+
const snapshot = await getSnapshot(ctx);
|
|
163
188
|
const task = findRunByWorkerRunId(snapshot.tasks, snapshot.batches, workerRunId);
|
|
164
189
|
const artifacts = [];
|
|
165
190
|
const seen = new Set();
|
|
@@ -190,7 +215,7 @@ async function handleRunArtifacts(_req, res, match, ctx) {
|
|
|
190
215
|
sendJson(res, 200, { artifacts });
|
|
191
216
|
}
|
|
192
217
|
async function handleDagRunById(_req, res, match, ctx) {
|
|
193
|
-
const snapshot = await
|
|
218
|
+
const snapshot = await getSnapshot(ctx);
|
|
194
219
|
const dagRun = snapshot.dagRuns.find((d) => d.dagRunId === match.params.id);
|
|
195
220
|
if (!dagRun) {
|
|
196
221
|
sendJson(res, 404, { error: "DAG run not found" });
|
|
@@ -206,7 +231,7 @@ async function handleDagNodeSessionEvents(_req, res, match, ctx) {
|
|
|
206
231
|
return;
|
|
207
232
|
}
|
|
208
233
|
const after = parseNonNegativeInt(match.query.get("after"), 0);
|
|
209
|
-
const snapshot = await
|
|
234
|
+
const snapshot = await getSnapshot(ctx);
|
|
210
235
|
const dagRun = snapshot.dagRuns.find((d) => d.dagRunId === dagRunId);
|
|
211
236
|
const eventsPath = resolveSessionEventsPath(ctx.repoRoot, dagRunId, nodeId, dagRun?.dagPath);
|
|
212
237
|
if (!eventsPath) {
|
|
@@ -258,9 +283,29 @@ async function handleEventStream(req, res, match, ctx) {
|
|
|
258
283
|
const observabilityRoot = path.join(getTaskPoolRoot(ctx.repoRoot), "observability");
|
|
259
284
|
const eventsPath = path.join(observabilityRoot, "events.jsonl");
|
|
260
285
|
let offset = 0;
|
|
286
|
+
let knownFileSize;
|
|
287
|
+
let timer;
|
|
288
|
+
let closed = false;
|
|
261
289
|
const sendMatchingEvents = async (initial) => {
|
|
290
|
+
let currentFileSize;
|
|
291
|
+
try {
|
|
292
|
+
currentFileSize = (await stat(eventsPath)).size;
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
knownFileSize = undefined;
|
|
296
|
+
offset = 0;
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (!shouldReadSseEventFile(knownFileSize, currentFileSize))
|
|
300
|
+
return;
|
|
301
|
+
if (knownFileSize !== undefined && currentFileSize < knownFileSize) {
|
|
302
|
+
offset = 0;
|
|
303
|
+
}
|
|
262
304
|
const page = await readFilteredEvents(eventsPath, offset, batchRunId, ctx.repoRoot);
|
|
305
|
+
if (!page.ok)
|
|
306
|
+
return;
|
|
263
307
|
offset = page.nextOffset;
|
|
308
|
+
knownFileSize = currentFileSize;
|
|
264
309
|
for (const event of page.events) {
|
|
265
310
|
if (initial || !res.writableEnded) {
|
|
266
311
|
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
@@ -268,15 +313,25 @@ async function handleEventStream(req, res, match, ctx) {
|
|
|
268
313
|
}
|
|
269
314
|
};
|
|
270
315
|
await sendMatchingEvents(true);
|
|
271
|
-
const
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
316
|
+
const scheduleNextPoll = () => {
|
|
317
|
+
if (closed)
|
|
318
|
+
return;
|
|
319
|
+
timer = setTimeout(() => {
|
|
320
|
+
timer = undefined;
|
|
321
|
+
void sendMatchingEvents(false)
|
|
322
|
+
.then(scheduleNextPoll)
|
|
323
|
+
.catch(() => {
|
|
324
|
+
closed = true;
|
|
325
|
+
if (!res.writableEnded)
|
|
326
|
+
res.end();
|
|
327
|
+
});
|
|
328
|
+
}, ctx.pollIntervalMs);
|
|
329
|
+
};
|
|
330
|
+
scheduleNextPoll();
|
|
278
331
|
req.on("close", () => {
|
|
279
|
-
|
|
332
|
+
closed = true;
|
|
333
|
+
if (timer)
|
|
334
|
+
clearTimeout(timer);
|
|
280
335
|
if (!res.writableEnded)
|
|
281
336
|
res.end();
|
|
282
337
|
});
|
|
@@ -368,12 +423,15 @@ async function readFilteredEvents(filePath, offset, batchRunId, repoRoot) {
|
|
|
368
423
|
events.push(normalizeEventArtifactRefs(repoRoot, event));
|
|
369
424
|
}
|
|
370
425
|
}
|
|
371
|
-
return { events, nextOffset: lines.length };
|
|
426
|
+
return { events, nextOffset: lines.length, ok: true };
|
|
372
427
|
}
|
|
373
428
|
catch {
|
|
374
|
-
return { events: [], nextOffset: offset };
|
|
429
|
+
return { events: [], nextOffset: offset, ok: false };
|
|
375
430
|
}
|
|
376
431
|
}
|
|
432
|
+
export function shouldReadSseEventFile(previousSize, currentSize) {
|
|
433
|
+
return previousSize !== currentSize;
|
|
434
|
+
}
|
|
377
435
|
function normalizeEventArtifactRefs(repoRoot, value) {
|
|
378
436
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
379
437
|
return value;
|