@tea-agent/loop-agent 0.5.0 → 0.6.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.
Files changed (133) hide show
  1. package/AGENTS.md +142 -142
  2. package/CHANGELOG.md +116 -98
  3. package/README.md +195 -195
  4. package/bin/agent-worker.js +22 -22
  5. package/bin/loop-agent.js +21 -21
  6. package/dist/application/dag/args.js +9 -1
  7. package/dist/application/dag/run-dag.js +16 -2
  8. package/dist/cli/command-definitions.js +22 -4
  9. package/dist/cli/help.js +3 -2
  10. package/dist/cli/program.js +7 -5
  11. package/dist/commands/import-prd.js +76 -0
  12. package/dist/commands/init.js +467 -457
  13. package/dist/commands/instructions.js +90 -58
  14. package/dist/commands/loop-benchmark.js +11 -11
  15. package/dist/commands/pi-reuse-benchmark.js +16 -16
  16. package/dist/executors/cursor-executor.js +1 -1
  17. package/dist/executors/dag-pi-executor.js +1 -0
  18. package/dist/executors/pi-sdk-executor.js +63 -1
  19. package/dist/shared/preview.js +39 -0
  20. package/dist/task/runtime.js +27 -27
  21. package/dist/task/source-references.js +221 -0
  22. package/dist/worker/cli.js +62 -1
  23. package/dist/worker/loop-agent/loop-agent-client.js +97 -5
  24. package/dist/worker/materialize/harness-task-materializer.js +162 -5
  25. package/dist/worker/observability/event-store.js +82 -0
  26. package/dist/worker/observability/events.js +79 -0
  27. package/dist/worker/observability/progress-composite.js +33 -0
  28. package/dist/worker/observability/read-model.js +1013 -0
  29. package/dist/worker/observability/snapshot-store.js +43 -0
  30. package/dist/worker/observability/types.js +1 -0
  31. package/dist/worker/observe/paths.js +64 -0
  32. package/dist/worker/observe/routes.js +423 -0
  33. package/dist/worker/observe/server.js +61 -0
  34. package/dist/worker/observe/static/app.js +1419 -0
  35. package/dist/worker/observe/static/index.html +63 -0
  36. package/dist/worker/observe/static/styles.css +613 -0
  37. package/dist/worker/pool/failure-routing.js +41 -6
  38. package/dist/worker/pool/run-store.js +50 -0
  39. package/dist/worker/progress-reporter.js +0 -18
  40. package/dist/worker/run-task/run-task.js +327 -92
  41. package/dist/worker/runner/run-ready.js +112 -4
  42. package/dist/workflows/dag/canvas-observer.js +275 -275
  43. package/dist/workflows/dag/event-observer.js +132 -0
  44. package/dist/workflows/dag/init-hybrid.js +146 -13
  45. package/dist/workflows/dag/observer-compose.js +52 -0
  46. package/docs/README.md +74 -72
  47. package/docs/agent-dag-recovery-playbook.md +184 -184
  48. package/docs/agent-dag-runner.md +42 -42
  49. package/docs/architecture/runtime-boundaries.md +162 -147
  50. package/docs/cursor-executor-usage.md +25 -25
  51. package/docs/decisions/README.md +3 -3
  52. package/docs/design/README.md +49 -36
  53. package/docs/development-principles.md +73 -73
  54. package/docs/dynamic-workflow-dag-engine-roadmap.md +1749 -1749
  55. package/docs/exec-plans/README.md +6 -6
  56. package/docs/exec-plans/active/README.md +12 -7
  57. package/docs/exec-plans/completed/README.md +31 -19
  58. package/docs/feature-workflow.md +186 -186
  59. package/docs/harness-methodology-debugging.md +153 -153
  60. package/docs/harness-methodology-tdd.md +130 -130
  61. package/docs/harness-methodology-verification.md +27 -27
  62. package/docs/init-surface.manifest.json +205 -199
  63. package/docs/loop-agent-harness.md +55 -42
  64. package/docs/production-readiness.md +96 -96
  65. package/docs/progress/README.md +3 -3
  66. package/docs/reports/README.md +9 -5
  67. package/docs/skills/README.md +6 -6
  68. package/docs/skills/vetted-skill-registry.md +26 -26
  69. package/docs/templates/adr.md +60 -60
  70. package/docs/templates/agent-dag-authority-surface-audit.prompt.md +94 -94
  71. package/docs/templates/agent-dag-decision-envelope.schema.json +213 -213
  72. package/docs/templates/agent-dag-decision-gate-dogfood-report.md +117 -117
  73. package/docs/templates/agent-dag-decision-gate.prompt.md +246 -246
  74. package/docs/templates/agent-dag-process-supervisor.prompt.md +98 -98
  75. package/docs/templates/agent-dag-report.schema.json +454 -454
  76. package/docs/templates/agent-dag-review-verdict.prompt.md +68 -68
  77. package/docs/templates/agent-dag.base.json +195 -195
  78. package/docs/templates/agent-dag.final-verification.json +190 -190
  79. package/docs/templates/agent-dag.schema.json +316 -316
  80. package/docs/templates/agent-dag.supervised-implementation.json +500 -500
  81. package/docs/templates/exec-plan.md +64 -64
  82. package/docs/templates/feature-spec.md +53 -53
  83. package/docs/templates/hybrid-dag.json +193 -193
  84. package/docs/templates/init-evolution-review.md +33 -33
  85. package/docs/templates/production-readiness-checklist.md +57 -57
  86. package/docs/templates/progress-log.md +17 -17
  87. package/docs/templates/project-start-checklist.md +9 -9
  88. package/docs/templates/qa-report.md +48 -48
  89. package/docs/templates/sprint-contract.md +29 -29
  90. package/docs/templates/worker-dogfood-evidence.md +52 -0
  91. package/docs/templates/worker-dogfood-setup.md +48 -0
  92. package/docs/verification-matrix.md +41 -41
  93. package/examples/decision-gate-agent-dag.json +123 -123
  94. package/examples/example-dag.json +51 -51
  95. package/examples/hybrid-loop-agent-dag.json +194 -194
  96. package/harness.json +70 -69
  97. package/package.json +66 -66
  98. package/skills/ai-engineering-context/SKILL.md +48 -48
  99. package/skills/code-review-core/SKILL.md +20 -20
  100. package/skills/codebase-scout/SKILL.md +19 -19
  101. package/skills/init-capability-evolution/SKILL.md +69 -69
  102. package/skills/loop-agent/SKILL.md +149 -147
  103. package/skills/loop-agent/references/README.md +67 -67
  104. package/skills/loop-agent/references/command-reference.md +412 -403
  105. package/skills/loop-agent/references/harness-policy.md +263 -259
  106. package/skills/loop-agent/references/hybrid-dag.md +216 -216
  107. package/skills/loop-agent/references/learned/README.md +21 -21
  108. package/skills/loop-agent/references/long-running-loop.md +59 -59
  109. package/skills/loop-agent/references/model-routing.md +36 -36
  110. package/skills/loop-agent/references/multi-worktree.md +54 -54
  111. package/skills/loop-agent/references/one-shot-runs.md +85 -85
  112. package/skills/loop-agent/references/orchestrator-and-interventions.md +169 -169
  113. package/skills/loop-agent/references/pi-prompt.md +23 -23
  114. package/skills/loop-agent/references/pi-subagent-assisted-mode.md +81 -81
  115. package/skills/loop-agent/references/post-implementation-and-patterns.md +44 -44
  116. package/skills/loop-agent/references/task-workflow.md +89 -84
  117. package/skills/loop-agent/references/verification-and-failure-handling.md +128 -128
  118. package/skills/requesting-code-review/SKILL.md +101 -101
  119. package/skills/requesting-code-review/code-reviewer.md +168 -168
  120. package/skills/systematic-debugging/CREATION-LOG.md +119 -119
  121. package/skills/systematic-debugging/SKILL.md +296 -296
  122. package/skills/systematic-debugging/condition-based-waiting-example.ts +158 -158
  123. package/skills/systematic-debugging/condition-based-waiting.md +115 -115
  124. package/skills/systematic-debugging/defense-in-depth.md +122 -122
  125. package/skills/systematic-debugging/find-polluter.sh +63 -63
  126. package/skills/systematic-debugging/root-cause-tracing.md +169 -169
  127. package/skills/systematic-debugging/test-academic.md +14 -14
  128. package/skills/systematic-debugging/test-pressure-1.md +58 -58
  129. package/skills/systematic-debugging/test-pressure-2.md +68 -68
  130. package/skills/systematic-debugging/test-pressure-3.md +69 -69
  131. package/skills/test-driven-development/SKILL.md +20 -20
  132. package/skills/verification-before-completion/SKILL.md +154 -154
  133. package/skills/webapp-testing/SKILL.md +19 -19
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Deterministic source reference materialization for task harnesses.
3
+ *
4
+ * Keeps user-authored PRD / reference docs as immutable copies under
5
+ * source/references/, separate from the derived source/需求.md contract.
6
+ */
7
+ import { createHash } from "node:crypto";
8
+ import { access, copyFile, mkdir, readdir, readFile, writeFile, } from "node:fs/promises";
9
+ import path from "node:path";
10
+ import { getTaskPaths, loadTaskConfig } from "./runtime.js";
11
+ import { writeTaskConfig } from "../infrastructure/harness/task-store.js";
12
+ export const SOURCE_REFERENCE_DIRECTORY = "references";
13
+ export const SOURCE_MANIFEST_FILE = "source-manifest.json";
14
+ function toPosixRelative(from, to) {
15
+ return path.relative(from, to).split(path.sep).join("/");
16
+ }
17
+ function slugifyName(value) {
18
+ const trimmed = value.trim().toLowerCase();
19
+ const slug = trimmed
20
+ .replace(/[^a-z0-9._-]+/g, "-")
21
+ .replace(/^-+|-+$/g, "")
22
+ .replace(/-{2,}/g, "-");
23
+ return slug || "requirement";
24
+ }
25
+ function ensureUniqueReferenceName(existing, baseName, extension) {
26
+ const used = new Set(existing);
27
+ const candidate = `${baseName}${extension}`;
28
+ if (!used.has(candidate))
29
+ return candidate;
30
+ let index = 2;
31
+ while (used.has(`${baseName}-${index}${extension}`)) {
32
+ index += 1;
33
+ }
34
+ return `${baseName}-${index}${extension}`;
35
+ }
36
+ export function getSourceManifestPath(sourceDir) {
37
+ return path.join(sourceDir, SOURCE_MANIFEST_FILE);
38
+ }
39
+ export async function readSourceManifest(sourceDir) {
40
+ try {
41
+ const raw = await readFile(getSourceManifestPath(sourceDir), "utf-8");
42
+ return JSON.parse(raw);
43
+ }
44
+ catch (error) {
45
+ if (error &&
46
+ typeof error === "object" &&
47
+ "code" in error &&
48
+ error.code === "ENOENT") {
49
+ return null;
50
+ }
51
+ throw error;
52
+ }
53
+ }
54
+ async function writeSourceManifest(sourceDir, manifest) {
55
+ await writeFile(getSourceManifestPath(sourceDir), `${JSON.stringify(manifest, null, 2)}\n`, "utf-8");
56
+ }
57
+ export async function hashFile(filePath) {
58
+ const content = await readFile(filePath);
59
+ return {
60
+ sha256: createHash("sha256").update(content).digest("hex"),
61
+ bytes: content.byteLength,
62
+ content,
63
+ };
64
+ }
65
+ /**
66
+ * Copy task.json.referenceDocs into source/references/ without rewriting content.
67
+ * Existing same-basename files are left untouched (immutable once present).
68
+ */
69
+ export async function materializeTaskReferenceDocs(input) {
70
+ const paths = getTaskPaths(input.repoRoot, input.taskId);
71
+ const taskConfig = input.taskConfig ?? (await loadTaskConfig(input.repoRoot, input.taskId));
72
+ const docs = taskConfig.referenceDocs ?? [];
73
+ if (docs.length === 0)
74
+ return [];
75
+ const referenceDir = path.join(paths.sourceDir, SOURCE_REFERENCE_DIRECTORY);
76
+ await mkdir(referenceDir, { recursive: true });
77
+ const materialized = [];
78
+ for (const doc of docs) {
79
+ const absoluteSource = path.isAbsolute(doc.path)
80
+ ? doc.path
81
+ : path.resolve(input.repoRoot, doc.path);
82
+ await assertReadableFile(absoluteSource, `referenceDocs entry "${doc.name ?? doc.path}"`);
83
+ const extension = path.extname(absoluteSource) || ".md";
84
+ const baseName = slugifyName(doc.name ?? path.basename(absoluteSource, extension));
85
+ const targetName = `${baseName}${extension}`;
86
+ const targetPath = path.join(referenceDir, targetName);
87
+ try {
88
+ await access(targetPath);
89
+ // Keep existing materialized copy immutable.
90
+ }
91
+ catch {
92
+ await copyFile(absoluteSource, targetPath);
93
+ }
94
+ materialized.push(targetPath);
95
+ }
96
+ return materialized;
97
+ }
98
+ export async function importPrdDocument(input) {
99
+ const now = input.now ?? new Date();
100
+ const paths = getTaskPaths(input.repoRoot, input.taskId);
101
+ await assertTaskExists(paths.taskConfigPath, input.taskId);
102
+ const absoluteSource = path.isAbsolute(input.filePath)
103
+ ? input.filePath
104
+ : path.resolve(input.repoRoot, input.filePath);
105
+ await assertReadableFile(absoluteSource, "PRD file");
106
+ const hash = await hashFile(absoluteSource);
107
+ const extension = path.extname(absoluteSource) || ".md";
108
+ const requestedName = slugifyName(input.name ?? "requirement");
109
+ const role = input.role ?? "requirement";
110
+ const referenceDir = path.join(paths.sourceDir, SOURCE_REFERENCE_DIRECTORY);
111
+ await mkdir(referenceDir, { recursive: true });
112
+ const existingManifest = await readSourceManifest(paths.sourceDir);
113
+ const existingNames = new Set((existingManifest?.documents ?? []).map((doc) => path.basename(doc.materializedPath)));
114
+ // Also avoid clobbering non-manifest files in references/.
115
+ try {
116
+ for (const entry of await readdir(referenceDir)) {
117
+ existingNames.add(entry);
118
+ }
119
+ }
120
+ catch {
121
+ // directory may be empty / just created
122
+ }
123
+ // Prefer stable name "requirement.md" when free; otherwise suffix.
124
+ const preferredName = `${requestedName}${extension}`;
125
+ const targetFileName = existingNames.has(preferredName)
126
+ ? // If the same content is already there, reuse; else unique name.
127
+ (await sameHashIfExists(path.join(referenceDir, preferredName), hash.sha256))
128
+ ? preferredName
129
+ : ensureUniqueReferenceName(existingNames, requestedName, extension)
130
+ : preferredName;
131
+ const targetPath = path.join(referenceDir, targetFileName);
132
+ if (!(await fileExists(targetPath))) {
133
+ await writeFile(targetPath, hash.content);
134
+ }
135
+ const importedAt = now.toISOString();
136
+ const sourcePathForRecord = path.isAbsolute(input.filePath)
137
+ ? toPosixRelative(input.repoRoot, absoluteSource)
138
+ : input.filePath.split(path.sep).join("/");
139
+ const materializedRelativePath = `${SOURCE_REFERENCE_DIRECTORY}/${targetFileName}`;
140
+ const document = {
141
+ role,
142
+ name: requestedName,
143
+ sourcePath: sourcePathForRecord,
144
+ materializedPath: materializedRelativePath,
145
+ sha256: hash.sha256,
146
+ bytes: hash.bytes,
147
+ importedAt,
148
+ };
149
+ const documents = [...(existingManifest?.documents ?? [])].filter((entry) => entry.materializedPath !== materializedRelativePath);
150
+ documents.push(document);
151
+ documents.sort((left, right) => left.materializedPath.localeCompare(right.materializedPath));
152
+ const manifest = {
153
+ schemaVersion: 1,
154
+ taskId: input.taskId,
155
+ updatedAt: importedAt,
156
+ documents,
157
+ };
158
+ await writeSourceManifest(paths.sourceDir, manifest);
159
+ const taskConfig = await loadTaskConfig(input.repoRoot, input.taskId);
160
+ const referenceDocs = [...(taskConfig.referenceDocs ?? [])];
161
+ const alreadyListed = referenceDocs.some((doc) => {
162
+ const absolute = path.isAbsolute(doc.path)
163
+ ? doc.path
164
+ : path.resolve(input.repoRoot, doc.path);
165
+ return absolute === absoluteSource;
166
+ });
167
+ if (!alreadyListed) {
168
+ referenceDocs.push({
169
+ name: requestedName,
170
+ path: sourcePathForRecord,
171
+ });
172
+ await writeTaskConfig(input.repoRoot, input.taskId, {
173
+ ...taskConfig,
174
+ referenceDocs,
175
+ });
176
+ }
177
+ return {
178
+ taskId: input.taskId,
179
+ sourcePath: sourcePathForRecord,
180
+ materializedAbsolutePath: targetPath,
181
+ materializedRelativePath,
182
+ sha256: hash.sha256,
183
+ bytes: hash.bytes,
184
+ manifestPath: getSourceManifestPath(paths.sourceDir),
185
+ referenceDocs: alreadyListed ? referenceDocs : [...referenceDocs],
186
+ };
187
+ }
188
+ async function sameHashIfExists(filePath, sha256) {
189
+ try {
190
+ const existing = await hashFile(filePath);
191
+ return existing.sha256 === sha256;
192
+ }
193
+ catch {
194
+ return false;
195
+ }
196
+ }
197
+ async function fileExists(filePath) {
198
+ try {
199
+ await access(filePath);
200
+ return true;
201
+ }
202
+ catch {
203
+ return false;
204
+ }
205
+ }
206
+ async function assertTaskExists(taskConfigPath, taskId) {
207
+ try {
208
+ await access(taskConfigPath);
209
+ }
210
+ catch {
211
+ throw new Error(`task not found: ${taskId} (expected ${taskConfigPath}). Run new-task first.`);
212
+ }
213
+ }
214
+ async function assertReadableFile(filePath, label) {
215
+ try {
216
+ await access(filePath);
217
+ }
218
+ catch {
219
+ throw new Error(`${label} is not readable: ${filePath}`);
220
+ }
221
+ }
@@ -9,6 +9,11 @@ import { resolveLoopAgentProfile } from "./profile-mapping.js";
9
9
  import { writeMorningReport } from "./report/morning-report.js";
10
10
  import { buildBatchRunId, runReadyTasks } from "./runner/run-ready.js";
11
11
  import { createProgressReporter } from "./progress-reporter.js";
12
+ import { createCompositeProgressReporter } from "./observability/progress-composite.js";
13
+ import { createRoutedWorkerEventStore } from "./observability/event-store.js";
14
+ import { buildGlobalSnapshot } from "./observability/read-model.js";
15
+ import { createObserveServer } from "./observe/server.js";
16
+ import { prepareTaskPoolRetry } from "./pool/run-store.js";
12
17
  import { taskSpecSchema } from "./task-spec/schema.js";
13
18
  import { validateTaskSpec } from "./task-spec/validate.js";
14
19
  export function buildAgentWorkerProgram() {
@@ -21,6 +26,7 @@ export function buildAgentWorkerProgram() {
21
26
  const task = program.command("task").description("TaskSpec utilities");
22
27
  const batch = program.command("batch").description("Task Pool batch utilities");
23
28
  const report = program.command("report").description("Task Pool reporting utilities");
29
+ const observe = program.command("observe").description("Observe UI server and snapshot utilities");
24
30
  task
25
31
  .command("validate")
26
32
  .argument("<task-yaml>", "TaskSpec YAML file")
@@ -43,6 +49,20 @@ export function buildAgentWorkerProgram() {
43
49
  const result = resolveLoopAgentProfile(parsed);
44
50
  process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
45
51
  });
52
+ task
53
+ .command("retry")
54
+ .argument("<task-id>", "Failed Task Pool task to requeue")
55
+ .requiredOption("--repo <repo-root>", "Target repo root")
56
+ .option("--reason <reason>", "Why the failure is safe to retry")
57
+ .description("Explicitly requeue a failed task; the next run-ready uses a new workerRunId")
58
+ .action(async (taskId, options) => {
59
+ const result = await prepareTaskPoolRetry({
60
+ repoRoot: path.resolve(options.repo),
61
+ taskId,
62
+ ...(options.reason ? { reason: options.reason } : {}),
63
+ });
64
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
65
+ });
46
66
  batch
47
67
  .command("run-ready")
48
68
  .requiredOption("--feature-dir <dir>", "Feature directory containing tasks/task-graph.yaml")
@@ -62,7 +82,18 @@ export function buildAgentWorkerProgram() {
62
82
  loopAgentBin: options.loopAgentBin,
63
83
  artifactRoot: path.join(repoRoot, ".task-pool", "artifacts", batchRunId),
64
84
  });
65
- const progress = createProgressReporter({ quiet: options.quiet ?? false });
85
+ let progress;
86
+ try {
87
+ const eventStore = createRoutedWorkerEventStore(repoRoot);
88
+ progress = createCompositeProgressReporter({
89
+ quiet: options.quiet ?? false,
90
+ eventStore,
91
+ context: { batchRunId },
92
+ });
93
+ }
94
+ catch {
95
+ progress = createProgressReporter({ quiet: options.quiet ?? false });
96
+ }
66
97
  const result = await runReadyTasks({
67
98
  repoRoot,
68
99
  featureDir: path.resolve(options.featureDir),
@@ -80,6 +111,36 @@ export function buildAgentWorkerProgram() {
80
111
  if (result.status !== "completed")
81
112
  process.exitCode = 1;
82
113
  });
114
+ observe
115
+ .command("serve")
116
+ .requiredOption("--repo <repo-root>", "Target repo root")
117
+ .option("--port <port>", "HTTP port", "8787")
118
+ .option("--host <host>", "Bind host", "127.0.0.1")
119
+ .description("Start read-only observe HTTP server")
120
+ .action(async (options) => {
121
+ const repoRoot = path.resolve(options.repo);
122
+ const server = await createObserveServer({
123
+ repoRoot,
124
+ host: options.host,
125
+ port: Number.parseInt(options.port, 10),
126
+ });
127
+ process.stdout.write(`${server.url}\n`);
128
+ await new Promise((resolve) => {
129
+ const shutdown = () => resolve();
130
+ process.once("SIGINT", shutdown);
131
+ process.once("SIGTERM", shutdown);
132
+ });
133
+ await server.close();
134
+ });
135
+ observe
136
+ .command("snapshot")
137
+ .requiredOption("--repo <repo-root>", "Target repo root")
138
+ .option("--json", "Emit JSON (default)")
139
+ .description("Print GlobalSnapshot JSON to stdout")
140
+ .action(async (options) => {
141
+ const snapshot = await buildGlobalSnapshot({ repoRoot: path.resolve(options.repo) });
142
+ process.stdout.write(`${JSON.stringify(snapshot)}\n`);
143
+ });
83
144
  report
84
145
  .command("morning")
85
146
  .requiredOption("--repo <repo-root>", "Target repo root")
@@ -1,13 +1,16 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { appendFileSync, writeFileSync } from "node:fs";
2
3
  import { mkdir, writeFile } from "node:fs/promises";
3
4
  import path from "node:path";
4
5
  import { parseCommandJson } from "./parse-json.js";
5
6
  export const DEFAULT_WORKER_COMMAND_TIMEOUT_MS = 120_000;
7
+ export const DEFAULT_HEARTBEAT_INTERVAL_MS = 15_000;
6
8
  export class LoopAgentClient {
7
9
  loopAgentBin;
8
10
  baseArgs;
9
11
  artifactRoot;
10
12
  defaultTimeoutMs;
13
+ heartbeatIntervalMs;
11
14
  env;
12
15
  constructor(options) {
13
16
  this.loopAgentBin = options.loopAgentBin;
@@ -15,6 +18,8 @@ export class LoopAgentClient {
15
18
  this.artifactRoot = options.artifactRoot;
16
19
  this.defaultTimeoutMs =
17
20
  options.defaultTimeoutMs ?? DEFAULT_WORKER_COMMAND_TIMEOUT_MS;
21
+ this.heartbeatIntervalMs =
22
+ options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
18
23
  this.env = options.env;
19
24
  }
20
25
  async run(args, options) {
@@ -27,12 +32,23 @@ export class LoopAgentClient {
27
32
  const startedAt = Date.now();
28
33
  const artifactDir = path.join(this.artifactRoot, sanitizeName(options.artifactName));
29
34
  await mkdir(artifactDir, { recursive: true });
35
+ const stdoutPath = path.join(artifactDir, "stdout.txt");
36
+ const stderrPath = path.join(artifactDir, "stderr.txt");
37
+ const resultPath = path.join(artifactDir, "result.json");
30
38
  const { stdout, stderr, exitCode, timedOut } = await spawnCommand({
31
39
  command,
32
40
  args: commandArgs,
33
41
  cwd: options.cwd,
34
42
  timeoutMs: options.timeoutMs ?? this.defaultTimeoutMs,
35
43
  env: { ...process.env, ...this.env, ...options.env },
44
+ stdoutPath,
45
+ stderrPath,
46
+ resultPath,
47
+ heartbeatIntervalMs: this.heartbeatIntervalMs,
48
+ onSpawn: options.onSpawn,
49
+ onStdout: options.onStdout,
50
+ onStderr: options.onStderr,
51
+ onHeartbeat: options.onHeartbeat,
36
52
  });
37
53
  const result = {
38
54
  ok: exitCode === 0 && !timedOut,
@@ -46,9 +62,9 @@ export class LoopAgentClient {
46
62
  timedOut,
47
63
  artifacts: {
48
64
  dir: artifactDir,
49
- stdoutPath: path.join(artifactDir, "stdout.txt"),
50
- stderrPath: path.join(artifactDir, "stderr.txt"),
51
- resultPath: path.join(artifactDir, "result.json"),
65
+ stdoutPath,
66
+ stderrPath,
67
+ resultPath,
52
68
  },
53
69
  };
54
70
  if (options.expectJson) {
@@ -69,12 +85,30 @@ export class LoopAgentClient {
69
85
  }
70
86
  function spawnCommand(input) {
71
87
  return new Promise((resolve, reject) => {
88
+ const startedAtMs = Date.now();
89
+ const startedAt = new Date(startedAtMs).toISOString();
72
90
  const child = spawn(input.command, input.args, {
73
91
  cwd: input.cwd,
74
92
  env: input.env,
75
93
  shell: false,
76
94
  stdio: ["ignore", "pipe", "pipe"],
77
95
  });
96
+ try {
97
+ writeFileSync(input.stdoutPath, "", "utf-8");
98
+ writeFileSync(input.stderrPath, "", "utf-8");
99
+ }
100
+ catch {
101
+ // best-effort: degrade to in-memory buffer only
102
+ }
103
+ invokeSpawnCallback(input.onSpawn, {
104
+ pid: child.pid,
105
+ startedAt,
106
+ artifactRefs: {
107
+ stdoutPath: input.stdoutPath,
108
+ stderrPath: input.stderrPath,
109
+ resultPath: input.resultPath,
110
+ },
111
+ });
78
112
  let stdout = "";
79
113
  let stderr = "";
80
114
  let timedOut = false;
@@ -82,24 +116,82 @@ function spawnCommand(input) {
82
116
  timedOut = true;
83
117
  child.kill("SIGTERM");
84
118
  }, input.timeoutMs);
119
+ let heartbeatTimer;
120
+ if (input.heartbeatIntervalMs > 0) {
121
+ heartbeatTimer = setInterval(() => {
122
+ invokeHeartbeatCallback(input.onHeartbeat, startedAtMs);
123
+ }, input.heartbeatIntervalMs);
124
+ }
125
+ const clearTimers = () => {
126
+ clearTimeout(timeout);
127
+ if (heartbeatTimer !== undefined) {
128
+ clearInterval(heartbeatTimer);
129
+ heartbeatTimer = undefined;
130
+ }
131
+ };
85
132
  child.stdout.setEncoding("utf8");
86
133
  child.stderr.setEncoding("utf8");
87
134
  child.stdout.on("data", (chunk) => {
88
135
  stdout += chunk;
136
+ appendChunkBestEffort(input.stdoutPath, chunk);
137
+ invokeChunkCallback(input.onStdout, chunk);
89
138
  });
90
139
  child.stderr.on("data", (chunk) => {
91
140
  stderr += chunk;
141
+ appendChunkBestEffort(input.stderrPath, chunk);
142
+ invokeChunkCallback(input.onStderr, chunk);
92
143
  });
93
144
  child.on("error", (error) => {
94
- clearTimeout(timeout);
145
+ clearTimers();
95
146
  reject(error);
96
147
  });
97
148
  child.on("close", (exitCode) => {
98
- clearTimeout(timeout);
149
+ clearTimers();
99
150
  resolve({ stdout, stderr, exitCode, timedOut });
100
151
  });
101
152
  });
102
153
  }
154
+ function appendChunkBestEffort(filePath, chunk) {
155
+ try {
156
+ appendFileSync(filePath, chunk, "utf-8");
157
+ }
158
+ catch {
159
+ // best-effort: degrade to in-memory buffer only
160
+ }
161
+ }
162
+ function invokeSpawnCallback(callback, info) {
163
+ if (!callback) {
164
+ return;
165
+ }
166
+ try {
167
+ callback(info);
168
+ }
169
+ catch (error) {
170
+ process.stderr.write(`LoopAgentClient onSpawn callback error: ${String(error)}\n`);
171
+ }
172
+ }
173
+ function invokeChunkCallback(callback, chunk) {
174
+ if (!callback) {
175
+ return;
176
+ }
177
+ try {
178
+ callback(chunk);
179
+ }
180
+ catch (error) {
181
+ process.stderr.write(`LoopAgentClient output callback error: ${String(error)}\n`);
182
+ }
183
+ }
184
+ function invokeHeartbeatCallback(callback, startedAtMs) {
185
+ if (!callback) {
186
+ return;
187
+ }
188
+ try {
189
+ callback({ at: new Date().toISOString(), elapsedMs: Date.now() - startedAtMs });
190
+ }
191
+ catch (error) {
192
+ process.stderr.write(`LoopAgentClient onHeartbeat callback error: ${String(error)}\n`);
193
+ }
194
+ }
103
195
  function sanitizeName(name) {
104
196
  return name.replace(/[^a-zA-Z0-9._-]+/g, "-");
105
197
  }