@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.
Files changed (67) hide show
  1. package/AGENTS.md +2 -2
  2. package/CHANGELOG.md +48 -38
  3. package/README.md +3 -3
  4. package/dist/application/dag/args.js +9 -1
  5. package/dist/application/dag/run-dag.js +16 -2
  6. package/dist/application/dag/validate-dag.js +14 -1
  7. package/dist/cli/command-definitions.js +22 -4
  8. package/dist/cli/help.js +3 -2
  9. package/dist/cli/program.js +7 -5
  10. package/dist/commands/import-prd.js +76 -0
  11. package/dist/commands/init.js +230 -32
  12. package/dist/commands/instructions.js +90 -58
  13. package/dist/executors/config-core.js +3 -2
  14. package/dist/executors/dag-pi-executor.js +1 -0
  15. package/dist/executors/model-routing.js +43 -0
  16. package/dist/executors/pi-sdk-executor.js +63 -1
  17. package/dist/governance/manifest-types.js +9 -1
  18. package/dist/shared/preview.js +39 -0
  19. package/dist/task/source-references.js +221 -0
  20. package/dist/worker/cli.js +62 -1
  21. package/dist/worker/loop-agent/loop-agent-client.js +97 -5
  22. package/dist/worker/materialize/harness-task-materializer.js +162 -5
  23. package/dist/worker/observability/event-store.js +82 -0
  24. package/dist/worker/observability/events.js +79 -0
  25. package/dist/worker/observability/progress-composite.js +33 -0
  26. package/dist/worker/observability/read-model.js +1013 -0
  27. package/dist/worker/observability/snapshot-store.js +43 -0
  28. package/dist/worker/observability/types.js +1 -0
  29. package/dist/worker/observe/paths.js +64 -0
  30. package/dist/worker/observe/routes.js +423 -0
  31. package/dist/worker/observe/server.js +61 -0
  32. package/dist/worker/observe/static/app.js +1419 -0
  33. package/dist/worker/observe/static/index.html +63 -0
  34. package/dist/worker/observe/static/styles.css +613 -0
  35. package/dist/worker/pool/failure-routing.js +41 -6
  36. package/dist/worker/pool/run-store.js +59 -1
  37. package/dist/worker/progress-reporter.js +0 -18
  38. package/dist/worker/run-task/run-task.js +327 -92
  39. package/dist/worker/runner/run-ready.js +112 -4
  40. package/dist/workflows/dag/event-observer.js +132 -0
  41. package/dist/workflows/dag/init-hybrid.js +150 -26
  42. package/dist/workflows/dag/observer-compose.js +52 -0
  43. package/dist/workflows/dag/skill-instructions.js +4 -0
  44. package/dist/workflows/dag/types.js +1 -1
  45. package/dist/workflows/dag/validate.js +3 -2
  46. package/docs/README.md +2 -0
  47. package/docs/architecture/runtime-boundaries.md +18 -3
  48. package/docs/design/README.md +22 -9
  49. package/docs/exec-plans/active/README.md +6 -1
  50. package/docs/exec-plans/completed/README.md +12 -0
  51. package/docs/init-surface.manifest.json +32 -2
  52. package/docs/loop-agent-harness.md +13 -0
  53. package/docs/reports/README.md +4 -0
  54. package/docs/templates/agent-dag.base.json +1 -1
  55. package/docs/templates/agent-dag.final-verification.json +1 -1
  56. package/docs/templates/agent-dag.supervised-implementation.json +1 -1
  57. package/docs/templates/hybrid-dag.json +1 -1
  58. package/docs/templates/worker-dogfood-evidence.md +52 -0
  59. package/docs/templates/worker-dogfood-setup.md +48 -0
  60. package/examples/example-dag.json +1 -1
  61. package/examples/hybrid-loop-agent-dag.json +1 -1
  62. package/harness.json +5 -29
  63. package/package.json +6 -6
  64. package/skills/loop-agent/SKILL.md +5 -3
  65. package/skills/loop-agent/references/command-reference.md +12 -3
  66. package/skills/loop-agent/references/harness-policy.md +7 -3
  67. 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 { DEFAULT_DAG_MODELS } from './model-routing.js';
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
- return DEFAULT_DAG_MODELS[dagLevel] ?? resolveCursorModel({}, cursorConfig);
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
- stdoutLines.push(serializeSessionEvent(event));
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
- requiresApiKey: z.string().optional().default("CURSOR_API_KEY"),
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
+ }
@@ -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")