@tea-agent/loop-agent 0.4.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 +2 -2
- package/CHANGELOG.md +48 -38
- package/README.md +3 -3
- package/dist/application/dag/args.js +9 -1
- package/dist/application/dag/run-dag.js +16 -2
- package/dist/application/dag/validate-dag.js +14 -1
- 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 +230 -32
- package/dist/commands/instructions.js +90 -58
- package/dist/executors/config-core.js +3 -2
- package/dist/executors/dag-pi-executor.js +1 -0
- package/dist/executors/model-routing.js +43 -0
- package/dist/executors/pi-sdk-executor.js +63 -1
- package/dist/governance/manifest-types.js +9 -1
- package/dist/shared/preview.js +39 -0
- 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 +59 -1
- 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/event-observer.js +132 -0
- package/dist/workflows/dag/init-hybrid.js +150 -26
- package/dist/workflows/dag/observer-compose.js +52 -0
- package/dist/workflows/dag/skill-instructions.js +4 -0
- package/dist/workflows/dag/types.js +1 -1
- package/dist/workflows/dag/validate.js +3 -2
- package/docs/README.md +2 -0
- package/docs/architecture/runtime-boundaries.md +18 -3
- package/docs/design/README.md +22 -9
- package/docs/exec-plans/active/README.md +6 -1
- package/docs/exec-plans/completed/README.md +12 -0
- package/docs/init-surface.manifest.json +32 -2
- package/docs/loop-agent-harness.md +13 -0
- package/docs/reports/README.md +4 -0
- package/docs/templates/agent-dag.base.json +1 -1
- package/docs/templates/agent-dag.final-verification.json +1 -1
- package/docs/templates/agent-dag.supervised-implementation.json +1 -1
- package/docs/templates/hybrid-dag.json +1 -1
- package/docs/templates/worker-dogfood-evidence.md +52 -0
- package/docs/templates/worker-dogfood-setup.md +48 -0
- package/examples/example-dag.json +1 -1
- package/examples/hybrid-loop-agent-dag.json +1 -1
- package/harness.json +5 -29
- package/package.json +6 -6
- package/skills/loop-agent/SKILL.md +5 -3
- package/skills/loop-agent/references/command-reference.md +12 -3
- package/skills/loop-agent/references/harness-policy.md +7 -3
- package/skills/loop-agent/references/task-workflow.md +8 -3
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { DEFAULT_CURSOR_MODEL } from './cursor-executor.js';
|
|
2
|
-
import {
|
|
2
|
+
import { resolveExecutorModelMatrix } from './model-routing.js';
|
|
3
3
|
export const TASK_COMPLEXITY_TO_DAG = {
|
|
4
4
|
small: 'LOW',
|
|
5
5
|
medium: 'MED',
|
|
@@ -13,7 +13,8 @@ export function resolveCursorModelForTaskConfig(taskConfig, cursorConfig) {
|
|
|
13
13
|
return taskConfig.cursorModel.trim();
|
|
14
14
|
}
|
|
15
15
|
const dagLevel = TASK_COMPLEXITY_TO_DAG[taskConfig.complexity ?? 'medium'];
|
|
16
|
-
|
|
16
|
+
const matrix = resolveExecutorModelMatrix('cursor', cursorConfig);
|
|
17
|
+
return matrix[dagLevel] ?? resolveCursorModel({}, cursorConfig);
|
|
17
18
|
}
|
|
18
19
|
export function resolveTaskExecutor(taskConfig, override) {
|
|
19
20
|
if (override)
|
|
@@ -256,6 +256,7 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep) {
|
|
|
256
256
|
step,
|
|
257
257
|
toolNames: resolveDagPiToolNames(input.task),
|
|
258
258
|
userMessage: buildDagPiUserMessage(input.task, persona, step),
|
|
259
|
+
sessionEventsPath: path.join(meta.runDir, input.task.id, "session-events.jsonl"),
|
|
259
260
|
});
|
|
260
261
|
const context = {
|
|
261
262
|
channel: "dag",
|
|
@@ -1,9 +1,52 @@
|
|
|
1
|
+
import { DEFAULT_DAG_EXECUTOR_MODELS, } from '../workflows/dag/types.js';
|
|
1
2
|
export const DEFAULT_DAG_CURSOR_MODEL = "composer-2.5";
|
|
2
3
|
export const DEFAULT_DAG_MODELS = {
|
|
3
4
|
HIGH: "gpt-5.5",
|
|
4
5
|
MED: "composer-2.5",
|
|
5
6
|
LOW: "composer-2.5",
|
|
6
7
|
};
|
|
8
|
+
/**
|
|
9
|
+
* DAG executor model tier keys that may carry a per-complexity override.
|
|
10
|
+
*/
|
|
11
|
+
const EXECUTOR_MODEL_TIERS = ["LOW", "MED", "HIGH"];
|
|
12
|
+
/**
|
|
13
|
+
* Resolve the DAG executor model matrix for a single executor from its harness
|
|
14
|
+
* `executors.<name>` config.
|
|
15
|
+
*
|
|
16
|
+
* Priority per tier (LOW/MED/HIGH):
|
|
17
|
+
* 1. execConfig[tier] (truthy and !== "default" sentinel)
|
|
18
|
+
* 2. execConfig.defaultModel (truthy and !== "default" sentinel)
|
|
19
|
+
* 3. DEFAULT_DAG_EXECUTOR_MODELS[executor][tier]
|
|
20
|
+
*
|
|
21
|
+
* The "default" literal (injected by the schema `.default("default")`) and
|
|
22
|
+
* absent/undefined both mean "no override, fall through".
|
|
23
|
+
*/
|
|
24
|
+
export function resolveExecutorModelMatrix(executor, execConfig) {
|
|
25
|
+
const tierValue = (tier) => {
|
|
26
|
+
const tierOverride = execConfig?.[tier];
|
|
27
|
+
if (tierOverride && tierOverride !== "default")
|
|
28
|
+
return tierOverride;
|
|
29
|
+
const defaultModel = execConfig?.defaultModel;
|
|
30
|
+
if (defaultModel && defaultModel !== "default")
|
|
31
|
+
return defaultModel;
|
|
32
|
+
return DEFAULT_DAG_EXECUTOR_MODELS[executor][tier];
|
|
33
|
+
};
|
|
34
|
+
return {
|
|
35
|
+
LOW: tierValue("LOW"),
|
|
36
|
+
MED: tierValue("MED"),
|
|
37
|
+
HIGH: tierValue("HIGH"),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Resolve both pi and cursor DAG executor model matrices from a harness manifest.
|
|
42
|
+
* Single entry point for DAG generation and --strict-models baseline resolution.
|
|
43
|
+
*/
|
|
44
|
+
export function resolveExecutorModelMatrices(manifest) {
|
|
45
|
+
return {
|
|
46
|
+
pi: resolveExecutorModelMatrix("pi", manifest.executors?.pi),
|
|
47
|
+
cursor: resolveExecutorModelMatrix("cursor", manifest.executors?.cursor),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
7
50
|
export function resolveModelSelection(manifest, taskConfig, step, options) {
|
|
8
51
|
const retryAttempt = options?.retryAttempt ?? 0;
|
|
9
52
|
if (Object.keys(manifest.modelProfiles ?? {}).length === 0) {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { appendFile, mkdir } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
1
3
|
import { classifyPiFailure, DEFAULT_TIMEOUT_MS, extractAssistantTextFromPiJson, extractTokenUsageFromPiJson, } from './pi-executor.js';
|
|
2
4
|
import { serializeSessionEvent } from './pi-event-serializer.js';
|
|
3
5
|
let sdkSessionFactoryOverride;
|
|
@@ -132,6 +134,51 @@ async function createSdkSession(sdk, input, shared) {
|
|
|
132
134
|
});
|
|
133
135
|
return created.session;
|
|
134
136
|
}
|
|
137
|
+
/**
|
|
138
|
+
* Throttle high-noise Pi SDK events from session-events.jsonl.
|
|
139
|
+
* Always keeps tool lifecycle, turn/agent end, assistant messages, and errors.
|
|
140
|
+
* Skips thinking_delta and message_update token floods.
|
|
141
|
+
*/
|
|
142
|
+
export function shouldPersistSessionEvent(event) {
|
|
143
|
+
const type = typeof event.type === 'string' ? event.type : '';
|
|
144
|
+
if (type === 'tool_start' || type === 'tool_end'
|
|
145
|
+
|| type === 'tool_execution_start' || type === 'tool_execution_end'
|
|
146
|
+
|| type === 'turn_end' || type === 'agent_end'
|
|
147
|
+
|| type === 'assistant_message' || type === 'message_end') {
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
if (type === 'thinking_delta' || type === 'message_update') {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
if (type.includes('error') || event.isError === true) {
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
function createSessionEventAppender(filePath, onSessionEvent) {
|
|
159
|
+
let chain = Promise.resolve();
|
|
160
|
+
let dirEnsured = false;
|
|
161
|
+
return {
|
|
162
|
+
append(line, event) {
|
|
163
|
+
chain = chain.then(async () => {
|
|
164
|
+
try {
|
|
165
|
+
if (!dirEnsured) {
|
|
166
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
167
|
+
dirEnsured = true;
|
|
168
|
+
}
|
|
169
|
+
await appendFile(filePath, `${line}\n`, 'utf-8');
|
|
170
|
+
onSessionEvent?.(line, event);
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
// best-effort: never fail the pi step
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
},
|
|
177
|
+
drain() {
|
|
178
|
+
return chain;
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
}
|
|
135
182
|
async function resolveSdkSessionFactory(reuseScope) {
|
|
136
183
|
if (sdkSessionFactoryOverride)
|
|
137
184
|
return sdkSessionFactoryOverride;
|
|
@@ -168,6 +215,9 @@ export async function executeSingleSdkAttempt(options) {
|
|
|
168
215
|
const stdoutLines = [];
|
|
169
216
|
let session;
|
|
170
217
|
let timeoutHandle;
|
|
218
|
+
const sessionEventAppender = options.sessionEventsPath
|
|
219
|
+
? createSessionEventAppender(options.sessionEventsPath, options.onSessionEvent)
|
|
220
|
+
: undefined;
|
|
171
221
|
try {
|
|
172
222
|
const createSession = await resolveSdkSessionFactory(options.reuseScope);
|
|
173
223
|
session = await createSession({
|
|
@@ -179,7 +229,11 @@ export async function executeSingleSdkAttempt(options) {
|
|
|
179
229
|
thinking: modelConfig.thinking,
|
|
180
230
|
});
|
|
181
231
|
const unsubscribe = session.subscribe((event) => {
|
|
182
|
-
|
|
232
|
+
const line = serializeSessionEvent(event);
|
|
233
|
+
stdoutLines.push(line);
|
|
234
|
+
if (sessionEventAppender && shouldPersistSessionEvent(event)) {
|
|
235
|
+
sessionEventAppender.append(line, event);
|
|
236
|
+
}
|
|
183
237
|
});
|
|
184
238
|
const filePrefix = options.attachedFiles.map((file) => `@${file}`).join(' ');
|
|
185
239
|
const promptMessage = filePrefix
|
|
@@ -225,6 +279,14 @@ export async function executeSingleSdkAttempt(options) {
|
|
|
225
279
|
stderr = stderr ? `${stderr}\n${message}` : message;
|
|
226
280
|
}
|
|
227
281
|
}
|
|
282
|
+
if (sessionEventAppender) {
|
|
283
|
+
try {
|
|
284
|
+
await sessionEventAppender.drain();
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
// best-effort: never fail the pi step
|
|
288
|
+
}
|
|
289
|
+
}
|
|
228
290
|
}
|
|
229
291
|
const stdout = stdoutLines.join('\n');
|
|
230
292
|
const durationMs = Date.now() - startedAt;
|
|
@@ -21,7 +21,15 @@ export const executorManifestSchema = z.object({
|
|
|
21
21
|
description: z.string().optional(),
|
|
22
22
|
enabled: z.boolean().optional(),
|
|
23
23
|
defaultModel: z.string().optional().default("default"),
|
|
24
|
-
|
|
24
|
+
/**
|
|
25
|
+
* Per-complexity DAG model overrides. When set (and not the "default" sentinel),
|
|
26
|
+
* these take priority over defaultModel for the matching DAG executor tier.
|
|
27
|
+
* "default" literal and absent/undefined both mean "no override, fall through".
|
|
28
|
+
*/
|
|
29
|
+
LOW: z.string().optional(),
|
|
30
|
+
MED: z.string().optional(),
|
|
31
|
+
HIGH: z.string().optional(),
|
|
32
|
+
requiresApiKey: z.string().optional(),
|
|
25
33
|
});
|
|
26
34
|
export const workflowPolicyProfileNameSchema = z.enum([
|
|
27
35
|
"minimal",
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
const ENCODER = new TextEncoder();
|
|
2
|
+
const DECODER = new TextDecoder();
|
|
3
|
+
export function redactSecrets(text) {
|
|
4
|
+
return text
|
|
5
|
+
.replace(/\bBearer\s+[^\s"']+/gi, "Bearer ***")
|
|
6
|
+
.replace(/(\b[A-Z][A-Z0-9_]*=)[^\s]+/g, "$1***")
|
|
7
|
+
.replace(/(["']?(?:api[_-]?key|token|password|secret|authorization)["']?\s*[:=]\s*["']?)([^"'\s,}]+)/gi, "$1***");
|
|
8
|
+
}
|
|
9
|
+
export function truncateUtf8Preview(text, maxBytes = 4096) {
|
|
10
|
+
if (maxBytes <= 0)
|
|
11
|
+
return "";
|
|
12
|
+
const bytes = ENCODER.encode(text);
|
|
13
|
+
if (bytes.length <= maxBytes)
|
|
14
|
+
return text;
|
|
15
|
+
let prefixEnd = safeUtf8End(bytes, Math.max(0, maxBytes - 32));
|
|
16
|
+
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
17
|
+
const omittedBytes = bytes.length - prefixEnd;
|
|
18
|
+
const suffix = `…<truncated ${omittedBytes} bytes>`;
|
|
19
|
+
const suffixBytes = ENCODER.encode(suffix);
|
|
20
|
+
if (suffixBytes.length >= maxBytes) {
|
|
21
|
+
const suffixEnd = safeUtf8End(suffixBytes, maxBytes);
|
|
22
|
+
return DECODER.decode(suffixBytes.subarray(0, suffixEnd));
|
|
23
|
+
}
|
|
24
|
+
const nextPrefixEnd = safeUtf8End(bytes, maxBytes - suffixBytes.length);
|
|
25
|
+
if (nextPrefixEnd === prefixEnd) {
|
|
26
|
+
return DECODER.decode(bytes.subarray(0, prefixEnd)) + suffix;
|
|
27
|
+
}
|
|
28
|
+
prefixEnd = nextPrefixEnd;
|
|
29
|
+
}
|
|
30
|
+
const omittedBytes = bytes.length - prefixEnd;
|
|
31
|
+
return `${DECODER.decode(bytes.subarray(0, prefixEnd))}…<truncated ${omittedBytes} bytes>`;
|
|
32
|
+
}
|
|
33
|
+
function safeUtf8End(bytes, budget) {
|
|
34
|
+
let end = Math.min(bytes.length, Math.max(0, budget));
|
|
35
|
+
while (end > 0 && end < bytes.length && (bytes[end] & 0xc0) === 0x80) {
|
|
36
|
+
end -= 1;
|
|
37
|
+
}
|
|
38
|
+
return end;
|
|
39
|
+
}
|
|
@@ -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")
|