@tea-agent/loop-agent 0.11.0 → 0.12.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 (76) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +3 -2
  3. package/dist/application/dag/generate-task-dag.js +15 -0
  4. package/dist/application/dag/run-dag.js +10 -0
  5. package/dist/application/dag/validate-dag.js +11 -0
  6. package/dist/commands/init.js +74 -7
  7. package/dist/shared/package-metadata.js +135 -0
  8. package/dist/task/config-types.js +1 -0
  9. package/dist/worker/cli.js +3 -1
  10. package/dist/worker/observability/event-history.js +216 -0
  11. package/dist/worker/observability/read-model.js +312 -83
  12. package/dist/worker/observe/paths.js +17 -0
  13. package/dist/worker/observe/routes.js +165 -21
  14. package/dist/worker/observe/server.js +59 -1
  15. package/dist/worker/observe/static/api.js +27 -0
  16. package/dist/worker/observe/static/app.js +120 -2598
  17. package/dist/worker/observe/static/constants.js +148 -0
  18. package/dist/worker/observe/static/copy.js +67 -0
  19. package/dist/worker/observe/static/dag-helpers.js +172 -0
  20. package/dist/worker/observe/static/dag-model.js +72 -0
  21. package/dist/worker/observe/static/dom.js +61 -0
  22. package/dist/worker/observe/static/format-pool.js +67 -0
  23. package/dist/worker/observe/static/format.js +292 -0
  24. package/dist/worker/observe/static/index.html +300 -82
  25. package/dist/worker/observe/static/kpi.js +94 -0
  26. package/dist/worker/observe/static/relations.js +128 -0
  27. package/dist/worker/observe/static/router.js +85 -0
  28. package/dist/worker/observe/static/run-processing.js +148 -0
  29. package/dist/worker/observe/static/shell-chrome.js +68 -0
  30. package/dist/worker/observe/static/state.js +253 -0
  31. package/dist/worker/observe/static/styles.css +1719 -495
  32. package/dist/worker/observe/static/views/batch.js +226 -0
  33. package/dist/worker/observe/static/views/dag-graph.js +172 -0
  34. package/dist/worker/observe/static/views/dag-inspector.js +477 -0
  35. package/dist/worker/observe/static/views/dag.js +362 -0
  36. package/dist/worker/observe/static/views/dashboard.js +442 -0
  37. package/dist/worker/observe/static/views/failures.js +143 -0
  38. package/dist/worker/observe/static/views/feature.js +453 -0
  39. package/dist/worker/observe/static/views/pool.js +347 -0
  40. package/dist/worker/observe/static/views/run.js +453 -0
  41. package/dist/worker/observe/static/views/session-timeline.js +205 -0
  42. package/dist/worker/observe/static/views/shell.js +7 -0
  43. package/dist/worker/observe/static/views/task.js +260 -0
  44. package/dist/worker/observe/static/views/timeline.js +163 -0
  45. package/dist/workflows/dag/controller-identity.js +104 -0
  46. package/dist/workflows/dag/init-hybrid.js +396 -3
  47. package/dist/workflows/dag/node-execution.js +123 -29
  48. package/dist/workflows/dag/repair-artifact.js +91 -0
  49. package/dist/workflows/dag/report.js +50 -0
  50. package/dist/workflows/dag/retry-policy.js +138 -0
  51. package/dist/workflows/dag/runner.js +32 -0
  52. package/dist/workflows/dag/runtime-contract.js +87 -0
  53. package/dist/workflows/dag/skill-snapshot.js +2 -0
  54. package/dist/workflows/dag/types.js +44 -1
  55. package/dist/workflows/dag/validate.js +68 -4
  56. package/docs/agent-dag-runner.md +26 -1
  57. package/docs/architecture/dag-execution.md +6 -0
  58. package/docs/architecture/evolution.md +4 -3
  59. package/docs/architecture/facts-and-state.md +1 -1
  60. package/docs/design/README.md +4 -3
  61. package/docs/exec-plans/active/README.md +1 -3
  62. package/docs/exec-plans/completed/README.md +11 -0
  63. package/docs/feature-workflow.md +28 -0
  64. package/docs/progress/README.md +18 -0
  65. package/docs/reports/README.md +8 -2
  66. package/docs/templates/agent-dag-report.schema.json +17 -0
  67. package/docs/templates/agent-dag.schema.json +69 -1
  68. package/docs/templates/agent-dag.supervised-implementation.json +8 -2
  69. package/docs/templates/backend-test-dag.generate-pytest.prompt.md +139 -0
  70. package/docs/templates/backend-test-dag.json +276 -0
  71. package/docs/templates/backend-test-dag.retrospect.prompt.md +125 -0
  72. package/docs/templates/backend-test-dag.review-cases.prompt.md +81 -0
  73. package/package.json +1 -1
  74. package/skills/loop-agent/references/command-reference.md +1 -0
  75. package/skills/loop-agent/references/hybrid-dag.md +22 -3
  76. package/skills/loop-agent/references/verification-and-failure-handling.md +6 -0
@@ -0,0 +1,216 @@
1
+ import { open as openFile, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { getTaskPoolRoot } from "../pool/run-store.js";
4
+ import { isSafeObservabilityIdentifier } from "./event-store.js";
5
+ import { parseWorkerEventLine } from "./events.js";
6
+ /** History REST page size defaults (distinct from Task run history 20/100 and SSE line offsets). */
7
+ export const EVENT_HISTORY_DEFAULT_LIMIT = 50;
8
+ export const EVENT_HISTORY_MAX_LIMIT = 200;
9
+ /** Hard cap on bytes scanned from the end of a JSONL history file (AC-R4-02). */
10
+ export const EVENT_HISTORY_MAX_SCAN_BYTES = 4 * 1024 * 1024;
11
+ const CURSOR_PREFIX = "v1.";
12
+ export function clampEventHistoryLimit(limit) {
13
+ if (limit === undefined || limit === null || !Number.isFinite(limit)) {
14
+ return EVENT_HISTORY_DEFAULT_LIMIT;
15
+ }
16
+ const n = Math.trunc(Number(limit));
17
+ if (!Number.isFinite(n) || n < 1)
18
+ return EVENT_HISTORY_DEFAULT_LIMIT;
19
+ if (n > EVENT_HISTORY_MAX_LIMIT)
20
+ return EVENT_HISTORY_MAX_LIMIT;
21
+ return n;
22
+ }
23
+ export function encodeEventHistoryCursor(beforeByte) {
24
+ const payload = Buffer.from(JSON.stringify({ b: Math.max(0, Math.trunc(beforeByte)) }), "utf-8").toString("base64url");
25
+ return `${CURSOR_PREFIX}${payload}`;
26
+ }
27
+ export function decodeEventHistoryCursor(cursor) {
28
+ if (cursor === undefined || cursor === null || cursor === "")
29
+ return null;
30
+ if (!cursor.startsWith(CURSOR_PREFIX))
31
+ return null;
32
+ try {
33
+ const raw = Buffer.from(cursor.slice(CURSOR_PREFIX.length), "base64url").toString("utf-8");
34
+ const parsed = JSON.parse(raw);
35
+ if (parsed === null ||
36
+ typeof parsed !== "object" ||
37
+ Array.isArray(parsed) ||
38
+ !("b" in parsed)) {
39
+ return null;
40
+ }
41
+ const b = parsed.b;
42
+ if (typeof b !== "number" || !Number.isFinite(b) || b < 0)
43
+ return null;
44
+ return { beforeByte: Math.trunc(b) };
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ }
50
+ /**
51
+ * Pool global observability events (newest-first page).
52
+ * History cursor semantics: `cursor` / `nextCursor` are opaque; do not confuse with
53
+ * SSE/run `after` integer line offsets.
54
+ */
55
+ export async function listPoolEventHistory(repoRoot, options = {}) {
56
+ const filePath = path.join(getTaskPoolRoot(path.resolve(repoRoot)), "observability", "events.jsonl");
57
+ return readEventHistoryFile(filePath, options);
58
+ }
59
+ /**
60
+ * Batch-scoped observability events under observability/batches/<id>/events.jsonl.
61
+ */
62
+ export async function listBatchEventHistory(repoRoot, batchRunId, options = {}) {
63
+ const limit = clampEventHistoryLimit(options.limit);
64
+ if (!isSafeObservabilityIdentifier(batchRunId)) {
65
+ return {
66
+ events: [],
67
+ limit,
68
+ source: "missing",
69
+ ...(options.cursor ? { cursor: options.cursor } : {}),
70
+ };
71
+ }
72
+ const filePath = path.join(getTaskPoolRoot(path.resolve(repoRoot)), "observability", "batches", batchRunId, "events.jsonl");
73
+ return readEventHistoryFile(filePath, options);
74
+ }
75
+ /**
76
+ * Bounded tail scan of a JSONL events file. Does not load the entire file into memory
77
+ * or split unbounded content: reads at most EVENT_HISTORY_MAX_SCAN_BYTES from the end
78
+ * (or from before the cursor window).
79
+ */
80
+ export async function readEventHistoryFile(filePath, options = {}) {
81
+ const limit = clampEventHistoryLimit(options.limit);
82
+ const inputCursor = typeof options.cursor === "string" && options.cursor.length > 0
83
+ ? options.cursor
84
+ : undefined;
85
+ const decoded = decodeEventHistoryCursor(inputCursor);
86
+ let fileSize;
87
+ try {
88
+ const s = await stat(filePath);
89
+ if (!s.isFile()) {
90
+ return emptyPage(limit, inputCursor, "missing");
91
+ }
92
+ fileSize = s.size;
93
+ }
94
+ catch {
95
+ return emptyPage(limit, inputCursor, "missing");
96
+ }
97
+ if (fileSize === 0) {
98
+ return emptyPage(limit, inputCursor, "ok");
99
+ }
100
+ const beforeByte = decoded !== null ? Math.min(decoded.beforeByte, fileSize) : fileSize;
101
+ if (beforeByte <= 0) {
102
+ return emptyPage(limit, inputCursor, "ok");
103
+ }
104
+ const scanEnd = beforeByte;
105
+ const scanStart = Math.max(0, scanEnd - EVENT_HISTORY_MAX_SCAN_BYTES);
106
+ const truncated = scanStart > 0;
107
+ const { lines } = await readBoundedJsonlTail(filePath, {
108
+ beforeByte,
109
+ maxBytes: EVENT_HISTORY_MAX_SCAN_BYTES,
110
+ });
111
+ const parsed = [];
112
+ let corruptLineCount = 0;
113
+ for (const line of lines) {
114
+ if (line.startByte >= beforeByte)
115
+ continue;
116
+ const event = parseWorkerEventLine(line.text);
117
+ if (!event) {
118
+ corruptLineCount += 1;
119
+ continue;
120
+ }
121
+ parsed.push({ event, startByte: line.startByte });
122
+ }
123
+ // Newest first within the scanned window (file order is chronological append).
124
+ parsed.reverse();
125
+ const pageItems = parsed.slice(0, limit);
126
+ const hasMoreInScan = parsed.length > limit;
127
+ const oldest = pageItems[pageItems.length - 1];
128
+ const continuationByte = oldest?.startByte ?? lines[0]?.startByte ?? (truncated ? scanStart : undefined);
129
+ const nextCursor = continuationByte !== undefined && (hasMoreInScan || truncated)
130
+ ? encodeEventHistoryCursor(continuationByte)
131
+ : undefined;
132
+ return {
133
+ events: pageItems.map((item) => item.event),
134
+ limit,
135
+ source: "ok",
136
+ ...(inputCursor ? { cursor: inputCursor } : {}),
137
+ ...(nextCursor ? { nextCursor } : {}),
138
+ ...(truncated ? { truncated: true } : {}),
139
+ ...(corruptLineCount > 0 ? { corruptLineCount } : {}),
140
+ };
141
+ }
142
+ function emptyPage(limit, cursor, source) {
143
+ return {
144
+ events: [],
145
+ limit,
146
+ source,
147
+ ...(cursor ? { cursor } : {}),
148
+ };
149
+ }
150
+ export async function readBoundedJsonlTail(filePath, options = {}) {
151
+ let fileSize;
152
+ try {
153
+ const s = await stat(filePath);
154
+ if (!s.isFile())
155
+ return { lines: [], truncated: false };
156
+ fileSize = s.size;
157
+ }
158
+ catch {
159
+ return { lines: [], truncated: false };
160
+ }
161
+ const scanEnd = Math.min(options.beforeByte ?? fileSize, fileSize);
162
+ if (scanEnd <= 0)
163
+ return { lines: [], truncated: false };
164
+ const maxBytes = Math.max(1, options.maxBytes ?? EVENT_HISTORY_MAX_SCAN_BYTES);
165
+ const scanStart = Math.max(0, scanEnd - maxBytes);
166
+ const buffer = Buffer.alloc(scanEnd - scanStart);
167
+ const handle = await openFile(filePath, "r");
168
+ let bytesRead = 0;
169
+ try {
170
+ ({ bytesRead } = await handle.read(buffer, 0, buffer.length, scanStart));
171
+ }
172
+ finally {
173
+ await handle.close();
174
+ }
175
+ let lines = splitCompleteLines(buffer.subarray(0, bytesRead), scanStart, scanStart > 0, scanEnd < fileSize);
176
+ const maxLines = options.maxLines;
177
+ const lineTruncated = maxLines !== undefined && lines.length > maxLines;
178
+ if (lineTruncated) {
179
+ lines = lines.slice(-maxLines);
180
+ }
181
+ return {
182
+ lines,
183
+ truncated: scanStart > 0 || lineTruncated,
184
+ };
185
+ }
186
+ /**
187
+ * Split a byte window into complete UTF-8 lines with absolute start offsets.
188
+ * When truncated from the left, drop the first partial line.
189
+ */
190
+ function splitCompleteLines(chunk, chunkStartByte, leftTruncated, rightTruncated) {
191
+ const lines = [];
192
+ let start = 0;
193
+ if (leftTruncated) {
194
+ const firstNl = chunk.indexOf(0x0a);
195
+ if (firstNl < 0)
196
+ return [];
197
+ start = firstNl + 1;
198
+ }
199
+ while (start < chunk.length) {
200
+ const nl = chunk.indexOf(0x0a, start);
201
+ if (nl < 0 && rightTruncated)
202
+ break;
203
+ const end = nl < 0 ? chunk.length : nl;
204
+ let line = chunk.subarray(start, end);
205
+ if (line.at(-1) === 0x0d)
206
+ line = line.subarray(0, line.length - 1);
207
+ const text = line.toString("utf-8");
208
+ if (text.trim().length > 0) {
209
+ lines.push({ text, startByte: chunkStartByte + start });
210
+ }
211
+ if (nl < 0)
212
+ break;
213
+ start = nl + 1;
214
+ }
215
+ return lines;
216
+ }