@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.
- package/AGENTS.md +142 -142
- package/CHANGELOG.md +116 -98
- package/README.md +195 -195
- package/bin/agent-worker.js +22 -22
- package/bin/loop-agent.js +21 -21
- package/dist/application/dag/args.js +9 -1
- package/dist/application/dag/run-dag.js +16 -2
- package/dist/cli/command-definitions.js +22 -4
- package/dist/cli/help.js +3 -2
- package/dist/cli/program.js +7 -5
- package/dist/commands/import-prd.js +76 -0
- package/dist/commands/init.js +467 -457
- package/dist/commands/instructions.js +90 -58
- package/dist/commands/loop-benchmark.js +11 -11
- package/dist/commands/pi-reuse-benchmark.js +16 -16
- package/dist/executors/cursor-executor.js +1 -1
- package/dist/executors/dag-pi-executor.js +1 -0
- package/dist/executors/pi-sdk-executor.js +63 -1
- package/dist/shared/preview.js +39 -0
- package/dist/task/runtime.js +27 -27
- package/dist/task/source-references.js +221 -0
- package/dist/worker/cli.js +62 -1
- package/dist/worker/loop-agent/loop-agent-client.js +97 -5
- package/dist/worker/materialize/harness-task-materializer.js +162 -5
- package/dist/worker/observability/event-store.js +82 -0
- package/dist/worker/observability/events.js +79 -0
- package/dist/worker/observability/progress-composite.js +33 -0
- package/dist/worker/observability/read-model.js +1013 -0
- package/dist/worker/observability/snapshot-store.js +43 -0
- package/dist/worker/observability/types.js +1 -0
- package/dist/worker/observe/paths.js +64 -0
- package/dist/worker/observe/routes.js +423 -0
- package/dist/worker/observe/server.js +61 -0
- package/dist/worker/observe/static/app.js +1419 -0
- package/dist/worker/observe/static/index.html +63 -0
- package/dist/worker/observe/static/styles.css +613 -0
- package/dist/worker/pool/failure-routing.js +41 -6
- package/dist/worker/pool/run-store.js +50 -0
- package/dist/worker/progress-reporter.js +0 -18
- package/dist/worker/run-task/run-task.js +327 -92
- package/dist/worker/runner/run-ready.js +112 -4
- package/dist/workflows/dag/canvas-observer.js +275 -275
- package/dist/workflows/dag/event-observer.js +132 -0
- package/dist/workflows/dag/init-hybrid.js +146 -13
- package/dist/workflows/dag/observer-compose.js +52 -0
- package/docs/README.md +74 -72
- package/docs/agent-dag-recovery-playbook.md +184 -184
- package/docs/agent-dag-runner.md +42 -42
- package/docs/architecture/runtime-boundaries.md +162 -147
- package/docs/cursor-executor-usage.md +25 -25
- package/docs/decisions/README.md +3 -3
- package/docs/design/README.md +49 -36
- package/docs/development-principles.md +73 -73
- package/docs/dynamic-workflow-dag-engine-roadmap.md +1749 -1749
- package/docs/exec-plans/README.md +6 -6
- package/docs/exec-plans/active/README.md +12 -7
- package/docs/exec-plans/completed/README.md +31 -19
- package/docs/feature-workflow.md +186 -186
- package/docs/harness-methodology-debugging.md +153 -153
- package/docs/harness-methodology-tdd.md +130 -130
- package/docs/harness-methodology-verification.md +27 -27
- package/docs/init-surface.manifest.json +205 -199
- package/docs/loop-agent-harness.md +55 -42
- package/docs/production-readiness.md +96 -96
- package/docs/progress/README.md +3 -3
- package/docs/reports/README.md +9 -5
- package/docs/skills/README.md +6 -6
- package/docs/skills/vetted-skill-registry.md +26 -26
- package/docs/templates/adr.md +60 -60
- package/docs/templates/agent-dag-authority-surface-audit.prompt.md +94 -94
- package/docs/templates/agent-dag-decision-envelope.schema.json +213 -213
- package/docs/templates/agent-dag-decision-gate-dogfood-report.md +117 -117
- package/docs/templates/agent-dag-decision-gate.prompt.md +246 -246
- package/docs/templates/agent-dag-process-supervisor.prompt.md +98 -98
- package/docs/templates/agent-dag-report.schema.json +454 -454
- package/docs/templates/agent-dag-review-verdict.prompt.md +68 -68
- package/docs/templates/agent-dag.base.json +195 -195
- package/docs/templates/agent-dag.final-verification.json +190 -190
- package/docs/templates/agent-dag.schema.json +316 -316
- package/docs/templates/agent-dag.supervised-implementation.json +500 -500
- package/docs/templates/exec-plan.md +64 -64
- package/docs/templates/feature-spec.md +53 -53
- package/docs/templates/hybrid-dag.json +193 -193
- package/docs/templates/init-evolution-review.md +33 -33
- package/docs/templates/production-readiness-checklist.md +57 -57
- package/docs/templates/progress-log.md +17 -17
- package/docs/templates/project-start-checklist.md +9 -9
- package/docs/templates/qa-report.md +48 -48
- package/docs/templates/sprint-contract.md +29 -29
- package/docs/templates/worker-dogfood-evidence.md +52 -0
- package/docs/templates/worker-dogfood-setup.md +48 -0
- package/docs/verification-matrix.md +41 -41
- package/examples/decision-gate-agent-dag.json +123 -123
- package/examples/example-dag.json +51 -51
- package/examples/hybrid-loop-agent-dag.json +194 -194
- package/harness.json +70 -69
- package/package.json +66 -66
- package/skills/ai-engineering-context/SKILL.md +48 -48
- package/skills/code-review-core/SKILL.md +20 -20
- package/skills/codebase-scout/SKILL.md +19 -19
- package/skills/init-capability-evolution/SKILL.md +69 -69
- package/skills/loop-agent/SKILL.md +149 -147
- package/skills/loop-agent/references/README.md +67 -67
- package/skills/loop-agent/references/command-reference.md +412 -403
- package/skills/loop-agent/references/harness-policy.md +263 -259
- package/skills/loop-agent/references/hybrid-dag.md +216 -216
- package/skills/loop-agent/references/learned/README.md +21 -21
- package/skills/loop-agent/references/long-running-loop.md +59 -59
- package/skills/loop-agent/references/model-routing.md +36 -36
- package/skills/loop-agent/references/multi-worktree.md +54 -54
- package/skills/loop-agent/references/one-shot-runs.md +85 -85
- package/skills/loop-agent/references/orchestrator-and-interventions.md +169 -169
- package/skills/loop-agent/references/pi-prompt.md +23 -23
- package/skills/loop-agent/references/pi-subagent-assisted-mode.md +81 -81
- package/skills/loop-agent/references/post-implementation-and-patterns.md +44 -44
- package/skills/loop-agent/references/task-workflow.md +89 -84
- package/skills/loop-agent/references/verification-and-failure-handling.md +128 -128
- package/skills/requesting-code-review/SKILL.md +101 -101
- package/skills/requesting-code-review/code-reviewer.md +168 -168
- package/skills/systematic-debugging/CREATION-LOG.md +119 -119
- package/skills/systematic-debugging/SKILL.md +296 -296
- package/skills/systematic-debugging/condition-based-waiting-example.ts +158 -158
- package/skills/systematic-debugging/condition-based-waiting.md +115 -115
- package/skills/systematic-debugging/defense-in-depth.md +122 -122
- package/skills/systematic-debugging/find-polluter.sh +63 -63
- package/skills/systematic-debugging/root-cause-tracing.md +169 -169
- package/skills/systematic-debugging/test-academic.md +14 -14
- package/skills/systematic-debugging/test-pressure-1.md +58 -58
- package/skills/systematic-debugging/test-pressure-2.md +68 -68
- package/skills/systematic-debugging/test-pressure-3.md +69 -69
- package/skills/test-driven-development/SKILL.md +20 -20
- package/skills/verification-before-completion/SKILL.md +154 -154
- 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
|
+
}
|
package/dist/worker/cli.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
50
|
-
stderrPath
|
|
51
|
-
resultPath
|
|
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
|
-
|
|
145
|
+
clearTimers();
|
|
95
146
|
reject(error);
|
|
96
147
|
});
|
|
97
148
|
child.on("close", (exitCode) => {
|
|
98
|
-
|
|
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
|
}
|