@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 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 = Number(args[++i]);
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 = Number(args[++i]);
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
- stderr,
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 += chunk;
127
+ stdout = appendBoundedOutput(stdout, chunk);
128
+ appendOutputArtifact(input.outputArtifacts?.stdoutPath, chunk);
116
129
  });
117
130
  child.stderr.on("data", (chunk) => {
118
- stderr += chunk;
131
+ stderr = appendBoundedOutput(stderr, chunk);
132
+ appendOutputArtifact(input.outputArtifacts?.stderrPath, chunk);
119
133
  });
120
134
  child.on("error", (error) => {
121
- stderr += stderr ? `\n${error.message}` : error.message;
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 = false;
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
- let entries;
139
- try {
140
- entries = await readdir(dir, { withFileTypes: true });
141
- }
142
- catch {
143
- return [];
144
- }
145
- const nested = await Promise.all(entries.map(async (entry) => {
146
- const target = path.join(dir, entry.name);
147
- if (entry.isDirectory())
148
- return listFilesRecursive(target);
149
- return [target];
150
- }));
151
- return nested.flat();
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("parallel"),
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([]),
@@ -102,7 +102,7 @@ export async function createTask(repoRoot, taskId, title) {
102
102
  complexity: 'medium',
103
103
  dagFallbackReason: '',
104
104
  contextProfile: 'full',
105
- verifyMode: 'parallel',
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 parsed = parseCommandJson(stdout);
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 += chunk;
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 += chunk;
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({ stdout, stderr, exitCode, timedOut });
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 buildGlobalSnapshot({ repoRoot: ctx.repoRoot });
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 buildGlobalSnapshot({ repoRoot: ctx.repoRoot });
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 buildGlobalSnapshot({ repoRoot: ctx.repoRoot });
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 buildGlobalSnapshot({ repoRoot: ctx.repoRoot });
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 buildGlobalSnapshot({ repoRoot: ctx.repoRoot });
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 buildGlobalSnapshot({ repoRoot: ctx.repoRoot });
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 buildGlobalSnapshot({ repoRoot: ctx.repoRoot });
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 buildGlobalSnapshot({ repoRoot: ctx.repoRoot });
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 buildGlobalSnapshot({ repoRoot: ctx.repoRoot });
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 timer = setInterval(() => {
272
- void sendMatchingEvents(false).catch(() => {
273
- clearInterval(timer);
274
- if (!res.writableEnded)
275
- res.end();
276
- });
277
- }, ctx.pollIntervalMs);
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
- clearInterval(timer);
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;