@tea-agent/loop-agent 0.11.0 → 0.13.0-alpha.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 (123) hide show
  1. package/CHANGELOG.md +74 -1
  2. package/README.md +33 -4
  3. package/dist/application/dag/generate-task-dag.js +45 -0
  4. package/dist/application/dag/run-dag.js +10 -0
  5. package/dist/application/dag/validate-dag.js +11 -0
  6. package/dist/cli/command-definitions.js +10 -3
  7. package/dist/commands/init.js +74 -7
  8. package/dist/commands/knowledge.js +129 -31
  9. package/dist/governance/manifest-types.js +3 -0
  10. package/dist/shared/package-metadata.js +135 -0
  11. package/dist/task/config-types.js +6 -1
  12. package/dist/worker/cli.js +99 -2
  13. package/dist/worker/delivery/package.js +3 -3
  14. package/dist/worker/feature/decision-loader.js +37 -6
  15. package/dist/worker/feature/next-action.js +10 -2
  16. package/dist/worker/feature/ready-plan-projection.js +81 -0
  17. package/dist/worker/feature/reducer.js +2 -1
  18. package/dist/worker/feature/review.js +19 -2
  19. package/dist/worker/feature/run.js +27 -2
  20. package/dist/worker/follow-up/approve.js +5 -2
  21. package/dist/worker/follow-up/factory.js +1 -1
  22. package/dist/worker/observability/event-history.js +216 -0
  23. package/dist/worker/observability/read-model.js +552 -118
  24. package/dist/worker/observe/paths.js +17 -0
  25. package/dist/worker/observe/routes.js +310 -23
  26. package/dist/worker/observe/server.js +59 -1
  27. package/dist/worker/observe/spec-evidence.js +281 -0
  28. package/dist/worker/observe/static/api.js +46 -0
  29. package/dist/worker/observe/static/app.js +120 -2598
  30. package/dist/worker/observe/static/constants.js +148 -0
  31. package/dist/worker/observe/static/copy.js +67 -0
  32. package/dist/worker/observe/static/dag-helpers.js +172 -0
  33. package/dist/worker/observe/static/dag-model.js +72 -0
  34. package/dist/worker/observe/static/dom.js +61 -0
  35. package/dist/worker/observe/static/format-pool.js +67 -0
  36. package/dist/worker/observe/static/format.js +292 -0
  37. package/dist/worker/observe/static/index.html +300 -82
  38. package/dist/worker/observe/static/kpi.js +94 -0
  39. package/dist/worker/observe/static/relations.js +133 -0
  40. package/dist/worker/observe/static/router.js +93 -0
  41. package/dist/worker/observe/static/run-processing.js +148 -0
  42. package/dist/worker/observe/static/shell-chrome.js +68 -0
  43. package/dist/worker/observe/static/state.js +253 -0
  44. package/dist/worker/observe/static/styles.css +1731 -495
  45. package/dist/worker/observe/static/views/batch.js +227 -0
  46. package/dist/worker/observe/static/views/dag-graph.js +172 -0
  47. package/dist/worker/observe/static/views/dag-inspector.js +596 -0
  48. package/dist/worker/observe/static/views/dag.js +362 -0
  49. package/dist/worker/observe/static/views/dashboard.js +445 -0
  50. package/dist/worker/observe/static/views/failures.js +143 -0
  51. package/dist/worker/observe/static/views/feature.js +492 -0
  52. package/dist/worker/observe/static/views/pool.js +350 -0
  53. package/dist/worker/observe/static/views/run.js +453 -0
  54. package/dist/worker/observe/static/views/session-timeline.js +205 -0
  55. package/dist/worker/observe/static/views/shell.js +7 -0
  56. package/dist/worker/observe/static/views/task.js +314 -0
  57. package/dist/worker/observe/static/views/timeline.js +163 -0
  58. package/dist/worker/pool/doctor.js +165 -0
  59. package/dist/worker/pool/migrate-state.js +303 -0
  60. package/dist/worker/pool/run-store.js +205 -17
  61. package/dist/worker/pool/types.js +17 -1
  62. package/dist/worker/pool/validation.js +100 -15
  63. package/dist/worker/report/morning-report.js +12 -2
  64. package/dist/worker/runner/run-ready.js +41 -26
  65. package/dist/worker/task-graph/ready-planner.js +136 -0
  66. package/dist/workflows/dag/controller-identity.js +104 -0
  67. package/dist/workflows/dag/convergence/controller.js +16 -8
  68. package/dist/workflows/dag/failure-routing.js +12 -1
  69. package/dist/workflows/dag/init-hybrid.js +1233 -11
  70. package/dist/workflows/dag/node-execution.js +123 -29
  71. package/dist/workflows/dag/repair-artifact.js +91 -0
  72. package/dist/workflows/dag/report.js +50 -0
  73. package/dist/workflows/dag/retry-policy.js +138 -0
  74. package/dist/workflows/dag/runner.js +32 -0
  75. package/dist/workflows/dag/runtime-contract.js +87 -0
  76. package/dist/workflows/dag/skill-snapshot.js +2 -0
  77. package/dist/workflows/dag/types.js +45 -1
  78. package/dist/workflows/dag/validate.js +68 -4
  79. package/docs/README.md +1 -1
  80. package/docs/agent-dag-recovery-playbook.md +9 -0
  81. package/docs/agent-dag-runner.md +26 -1
  82. package/docs/architecture/dag-execution.md +6 -0
  83. package/docs/architecture/evolution.md +7 -5
  84. package/docs/architecture/facts-and-state.md +15 -2
  85. package/docs/architecture/worker-and-feature.md +6 -2
  86. package/docs/decisions/README.md +3 -0
  87. package/docs/design/README.md +12 -3
  88. package/docs/exec-plans/active/README.md +2 -2
  89. package/docs/exec-plans/completed/README.md +12 -0
  90. package/docs/feature-workflow.md +108 -2
  91. package/docs/loop-agent-harness.md +15 -4
  92. package/docs/progress/README.md +22 -0
  93. package/docs/reports/README.md +14 -2
  94. package/docs/templates/agent-dag-report.schema.json +17 -0
  95. package/docs/templates/agent-dag.schema.json +69 -1
  96. package/docs/templates/agent-dag.supervised-implementation.json +8 -2
  97. package/docs/templates/backend-test-dag.generate-pytest.prompt.md +139 -0
  98. package/docs/templates/backend-test-dag.json +288 -0
  99. package/docs/templates/backend-test-dag.retrospect.prompt.md +125 -0
  100. package/docs/templates/backend-test-dag.review-cases.prompt.md +81 -0
  101. package/docs/templates/knowledge-graph-bootstrap-dag.json +118 -0
  102. package/docs/templates/knowledge-sync-dag.json +177 -0
  103. package/docs/templates/knowledge-sync-draft.schema.json +71 -0
  104. package/docs/verification-matrix.md +2 -1
  105. package/package.json +8 -2
  106. package/scripts/kb-bootstrap-init-skeleton.sh +239 -0
  107. package/scripts/kb-graph-incremental-prepare.mjs +372 -0
  108. package/scripts/kb-graph-incremental-prepare.sh +5 -0
  109. package/scripts/kb-graph-materialize.mjs +105 -0
  110. package/scripts/kb-graph-materialize.sh +4 -0
  111. package/scripts/kb-graph-promote.mjs +153 -0
  112. package/scripts/kb-graph-promote.sh +4 -0
  113. package/scripts/kb-query.mjs +554 -0
  114. package/scripts/kb-query.sh +5 -0
  115. package/skills/agent-worker/SKILL.md +3 -1
  116. package/skills/agent-worker/references/agent-worker-operator.md +18 -1
  117. package/skills/frontend-design-review/SKILL.md +26 -24
  118. package/skills/frontend-implementation/SKILL.md +29 -26
  119. package/skills/frontend-implementation/references/node-contracts.md +50 -19
  120. package/skills/frontend-review/SKILL.md +1 -1
  121. package/skills/loop-agent/references/command-reference.md +2 -0
  122. package/skills/loop-agent/references/hybrid-dag.md +22 -3
  123. package/skills/loop-agent/references/verification-and-failure-handling.md +6 -0
@@ -0,0 +1,81 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import YAML from "yaml";
4
+ import { planReadyTasks, } from "../task-graph/ready-planner.js";
5
+ import { taskGraphSpecSchema } from "../task-graph/task-graph-schema.js";
6
+ import { taskSpecSchema } from "../task-spec/schema.js";
7
+ /** Single Feature-side entry to M1 planReadyTasks; never reimplements ordering. */
8
+ export function projectReadyPlan(input) {
9
+ return planReadyTasks(input);
10
+ }
11
+ /**
12
+ * Apply a tighter selectionLimit to an existing planner projection.
13
+ * Eligible order is preserved from planReadyTasks; this does not re-sort.
14
+ */
15
+ export function applySelectionLimit(plan, selectionLimit) {
16
+ if (!Number.isInteger(selectionLimit) || selectionLimit < 1) {
17
+ throw new Error("selectionLimit must be a positive integer");
18
+ }
19
+ const selected = plan.eligible.slice(0, selectionLimit);
20
+ const deferred = plan.eligible.slice(selectionLimit).map((candidate) => ({
21
+ ...candidate,
22
+ reasonCode: "selection-limit",
23
+ selectedAhead: selected.map((item) => item.taskId),
24
+ }));
25
+ return {
26
+ ...plan,
27
+ selectionLimit,
28
+ selected,
29
+ deferred,
30
+ summary: {
31
+ ...plan.summary,
32
+ selected: selected.length,
33
+ deferred: deferred.length,
34
+ },
35
+ };
36
+ }
37
+ export function selectedTaskIds(plan) {
38
+ return plan.selected.map((item) => item.taskId);
39
+ }
40
+ export function summarizePlanReasons(plan) {
41
+ return {
42
+ selected: plan.selected.map((item) => ({ taskId: item.taskId, priority: item.priority })),
43
+ deferred: plan.deferred.map((item) => ({
44
+ taskId: item.taskId,
45
+ reasonCode: item.reasonCode,
46
+ selectedAhead: item.selectedAhead,
47
+ })),
48
+ blocked: plan.blocked.map((item) => ({
49
+ taskId: item.taskId,
50
+ reasonCode: item.reasonCode,
51
+ reason: item.reason,
52
+ blockedBy: item.blockedBy,
53
+ })),
54
+ };
55
+ }
56
+ export async function loadReadyPlanInputs(featureDir) {
57
+ const resolved = path.resolve(featureDir);
58
+ const graphRaw = YAML.parse(await readFile(path.join(resolved, "tasks", "task-graph.yaml"), "utf-8"));
59
+ const graph = taskGraphSpecSchema.parse(graphRaw);
60
+ const taskSpecs = new Map();
61
+ for (const node of graph.nodes) {
62
+ const value = YAML.parse(await readFile(path.join(resolved, "tasks", node.task), "utf-8"));
63
+ taskSpecs.set(node.id, taskSpecSchema.parse(value));
64
+ }
65
+ return { featureId: graph.feature_id, graph, taskSpecs };
66
+ }
67
+ export async function projectReadyPlanForFeature(input) {
68
+ try {
69
+ const loaded = await loadReadyPlanInputs(input.featureDir);
70
+ return projectReadyPlan({
71
+ featureId: input.featureId || loaded.featureId,
72
+ graph: loaded.graph,
73
+ taskSpecs: loaded.taskSpecs,
74
+ states: input.states,
75
+ selectionLimit: input.selectionLimit,
76
+ });
77
+ }
78
+ catch {
79
+ return undefined;
80
+ }
81
+ }
@@ -10,7 +10,7 @@ const STATUS_LABELS = {
10
10
  };
11
11
  export function reduceFeature(input) {
12
12
  const status = deriveStatus(input);
13
- const readyTasks = input.tasks.filter((task) => isReady(task.taskId, input));
13
+ const readyTasks = input.planning?.eligible ?? input.tasks.filter((task) => isReady(task.taskId, input));
14
14
  const blockingItems = [];
15
15
  const resolvedFailures = new Set(input.resolvedFailureTaskIds ?? []);
16
16
  for (const error of input.validationErrors) {
@@ -88,6 +88,7 @@ export function reduceFeature(input) {
88
88
  closeout: input.closeout?.path ?? null,
89
89
  },
90
90
  projectionWarnings: [...input.projectionWarnings],
91
+ ...(input.planning ? { planning: input.planning } : {}),
91
92
  };
92
93
  }
93
94
  function deriveStatus(input) {
@@ -3,12 +3,14 @@ import { access, readFile, realpath } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import YAML from "yaml";
5
5
  import { z } from "zod";
6
- import { getRunsJsonlPath, getTaskPoolRoot, readAllTaskPoolStates, readJsonlFile } from "../pool/run-store.js";
6
+ import { getRunsJsonlPath, getTaskPoolRoot, readFeatureTaskPoolStates, readJsonlFile } from "../pool/run-store.js";
7
7
  import { acceptanceSpecSchema } from "../task-graph/acceptance-schema.js";
8
8
  import { taskGraphSpecSchema } from "../task-graph/task-graph-schema.js";
9
+ import { taskSpecSchema } from "../task-spec/schema.js";
9
10
  import { validateFeatureTaskGraph } from "../task-graph/validate.js";
10
11
  import { followUpActionCardSchema, followUpApprovalSchema, followUpDraftSchema, followUpEvidenceSchema } from "../follow-up/schema.js";
11
12
  import { readFollowUpIndex, resolveRepoFile, sha256File as sha256FollowUpFile } from "../follow-up/store.js";
13
+ import { projectReadyPlan } from "./ready-plan-projection.js";
12
14
  import { reduceFeature } from "./reducer.js";
13
15
  export async function reviewFeature(input) {
14
16
  const projection = await loadFeatureProjection(input);
@@ -67,7 +69,7 @@ async function loadFeatureProjection(input) {
67
69
  let states = {};
68
70
  let runs = [];
69
71
  try {
70
- states = await readAllTaskPoolStates(repoRoot);
72
+ states = await readFeatureTaskPoolStates(repoRoot, validation.featureId);
71
73
  runs = await readJsonlFile(getRunsJsonlPath(repoRoot));
72
74
  }
73
75
  catch (error) {
@@ -86,6 +88,20 @@ async function loadFeatureProjection(input) {
86
88
  return valid;
87
89
  });
88
90
  const graphNodes = graph.success ? graph.data.nodes : [];
91
+ let planning;
92
+ if (graph.success) {
93
+ try {
94
+ const taskSpecs = new Map();
95
+ for (const node of graph.data.nodes) {
96
+ const value = YAML.parse(await readFile(path.join(featureDir, "tasks", node.task), "utf-8"));
97
+ taskSpecs.set(node.id, taskSpecSchema.parse(value));
98
+ }
99
+ planning = projectReadyPlan({ featureId: validation.featureId, graph: graph.data, taskSpecs, states, selectionLimit: Math.max(graph.data.nodes.length, 1) });
100
+ }
101
+ catch (error) {
102
+ projectionWarnings.push(`Ready Planner projection is unavailable: ${message(error)}`);
103
+ }
104
+ }
89
105
  let pendingFollowUps = [];
90
106
  let actionCards = [];
91
107
  let resolvedFailureTaskIds = [];
@@ -292,6 +308,7 @@ async function loadFeatureProjection(input) {
292
308
  ...((await exists(morningReportPath)) ? { morningReport: morningReportPath } : {}),
293
309
  ...((await exists(observeSnapshotPath)) ? { observeSnapshot: observeSnapshotPath } : {}),
294
310
  projectionWarnings,
311
+ ...(planning ? { planning } : {}),
295
312
  };
296
313
  }
297
314
  async function exists(filePath) {
@@ -5,11 +5,12 @@ import { buildGlobalSnapshot } from "../observability/read-model.js";
5
5
  import { createSnapshotStore } from "../observability/snapshot-store.js";
6
6
  import { preflightTargetRepo } from "../preflight.js";
7
7
  import { noopProgressReporter } from "../progress-reporter.js";
8
- import { getTaskPoolRoot } from "../pool/run-store.js";
8
+ import { getTaskPoolRoot, listLegacyStateFiles } from "../pool/run-store.js";
9
9
  import { writeMorningReport } from "../report/morning-report.js";
10
10
  import { buildBatchRunId, runReadyTasks } from "../runner/run-ready.js";
11
11
  import { validateFeatureTaskGraph } from "../task-graph/validate.js";
12
12
  import { reviewFeature } from "./review.js";
13
+ import { applySelectionLimit, selectedTaskIds } from "./ready-plan-projection.js";
13
14
  import { finalizeGitTask, startGitTransaction } from "../delivery/git-transaction.js";
14
15
  export async function runFeature(options) {
15
16
  const featureDir = path.resolve(options.featureDir);
@@ -125,7 +126,13 @@ export async function runFeature(options) {
125
126
  controllerIdentity });
126
127
  }
127
128
  before = beforeStep.value;
128
- const readyTasks = before.tasks.filter((task) => task.status === "Ready").map((task) => task.taskId);
129
+ const fullReadyPlan = before.planning;
130
+ const readyPlan = fullReadyPlan
131
+ ? applySelectionLimit(fullReadyPlan, limitApplied)
132
+ : undefined;
133
+ const readyTasks = readyPlan
134
+ ? selectedTaskIds(readyPlan)
135
+ : before.tasks.filter((task) => task.status === "Ready").map((task) => task.taskId);
129
136
  if (options.dryRun) {
130
137
  steps.push({ ...planned("run-ready", readyTasks.length > 0 ? `execute at most ${limitApplied} Ready task` : "no Ready task"), artifacts: [expectedArtifacts.batchRun] }, { ...planned("morning-report", "refresh morning report"), artifacts: [expectedArtifacts.morningReport] }, { ...planned("observe-snapshot", "refresh Observe snapshot"), artifacts: [expectedArtifacts.observeSnapshot] }, planned("review-after", "derive final Feature review"));
131
138
  return {
@@ -138,6 +145,7 @@ export async function runFeature(options) {
138
145
  statusAfter: before.status,
139
146
  steps,
140
147
  readyTasks,
148
+ ...(readyPlan ? { readyPlan } : {}),
141
149
  executedTasks: [],
142
150
  artifacts: { batchRun: null, morningReport: null, observeSnapshot: null, ...(checkpointMode ? { gitTransaction: expectedArtifacts.gitTransaction ?? null } : {}) },
143
151
  expectedArtifacts,
@@ -151,6 +159,22 @@ export async function runFeature(options) {
151
159
  };
152
160
  }
153
161
  if (readyTasks.length > 0 && limitApplied > 0) {
162
+ const legacyStates = await listLegacyStateFiles(repoRoot);
163
+ if (legacyStates.length > 0) {
164
+ return failedResult({
165
+ featureId,
166
+ mode: "executed",
167
+ steps,
168
+ limitApplied,
169
+ code: "task-pool-state-migration-required",
170
+ message: `cannot execute Ready tasks while legacy Task Pool states require migration: ${legacyStates.map((item) => item.taskId).join(", ")}`,
171
+ evidence: legacyStates.map((item) => item.path),
172
+ expectedArtifacts,
173
+ controllerIdentity,
174
+ before,
175
+ readyTasks,
176
+ });
177
+ }
154
178
  progress.batch(`feature ${featureId}: executing ${Math.min(readyTasks.length, limitApplied)} of ${readyTasks.length} Ready task(s)`);
155
179
  const runStep = await timedStep(steps, "run-ready", async () => dependencies.runReady({
156
180
  repoRoot,
@@ -226,6 +250,7 @@ export async function runFeature(options) {
226
250
  statusAfter: after.value.status,
227
251
  steps,
228
252
  readyTasks,
253
+ ...(readyPlan ? { readyPlan } : {}),
229
254
  executedTasks: batch?.tasks ?? [],
230
255
  artifacts: { batchRun: batch?.batchRunPath ?? null, morningReport: report.value, observeSnapshot: snapshot.value, ...(gitTransaction ? { gitTransaction: gitTransaction.recordPath } : {}) },
231
256
  expectedArtifacts,
@@ -99,6 +99,8 @@ async function executeApproval(input) {
99
99
  }
100
100
  await input.faultInjector?.("before-state-write");
101
101
  await writeTaskPoolState(input.repoRoot, {
102
+ schemaVersion: 2,
103
+ featureId: input.featureId,
102
104
  taskId: loaded.draft.proposed_task_id,
103
105
  status: "Ready",
104
106
  updatedAt: (input.now ?? new Date()).toISOString(),
@@ -201,7 +203,7 @@ async function loadApprovalInputs(input) {
201
203
  entry, draft, draftPath, evidencePath, approvalPath, existingApproval,
202
204
  taskSpecPath: path.join(input.featureDir, "tasks", `${draft.proposed_task_id}.yaml`),
203
205
  graphPath: path.join(input.featureDir, "tasks", "task-graph.yaml"),
204
- statePath: getTaskStatePath(input.repoRoot, draft.proposed_task_id),
206
+ statePath: getTaskStatePath(input.repoRoot, { featureId: input.featureId, taskId: draft.proposed_task_id }),
205
207
  featureId: input.featureId,
206
208
  featureDir: input.featureDir,
207
209
  repoRoot: input.repoRoot,
@@ -237,8 +239,9 @@ async function assertApprovalPreconditions(loaded) {
237
239
  const graph = taskGraphSpecSchema.parse(YAML.parse(await readFile(loaded.graphPath, "utf-8")));
238
240
  if (graph.nodes.some((node) => node.id === loaded.draft.proposed_task_id))
239
241
  throw new Error(`proposed graph node already exists: ${loaded.draft.proposed_task_id}`);
240
- if (await readTaskPoolState(loaded.repoRoot, loaded.draft.proposed_task_id))
242
+ if (await readTaskPoolState(loaded.repoRoot, { featureId: loaded.featureId, taskId: loaded.draft.proposed_task_id })) {
241
243
  throw new Error(`Task Pool state already exists: ${loaded.draft.proposed_task_id}`);
244
+ }
242
245
  }
243
246
  function resultFromApproval(approval, repoRoot, approvalPath, idempotent) {
244
247
  return {
@@ -99,7 +99,7 @@ export async function draftFollowUpDecision(input, productBugOnly = false) {
99
99
  generated_from: { feature_id: run.featureId, task_id: input.taskId, worker_run_id: input.workerRunId, failure_category: category },
100
100
  action: policy.action,
101
101
  label: policy.label,
102
- ...(policy.recommendedCommand === "task-retry" ? { command: `agent-worker task retry ${JSON.stringify(input.taskId)} --repo ${JSON.stringify(repoRoot)}` } : {}),
102
+ ...(policy.recommendedCommand === "task-retry" ? { command: `agent-worker task retry ${JSON.stringify(input.taskId)} --feature-id ${JSON.stringify(run.featureId)} --repo ${JSON.stringify(repoRoot)}` } : {}),
103
103
  evidence_refs: evidenceRefs,
104
104
  dedupe_key: dedupeKey,
105
105
  created_at: (input.now ?? new Date()).toISOString(),
@@ -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
+ }