426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
328 lines
11 KiB
TypeScript
328 lines
11 KiB
TypeScript
/**
|
|
* Per-task trace usage roll-up (#13775 item 5). Drives the service's real
|
|
* getTraceUsage against on-disk child-trajectory files ingested as artifacts,
|
|
* asserting it sums the file-recorder metrics grouped by traceId — separate
|
|
* from the ACP-frame getUsage surface — and marks the roll-up partial when an
|
|
* unreadable/corrupt file prevents a complete accounting. Uses the injectable
|
|
* InMemoryTaskStore (real store) and the real ingest path; no mock stands in
|
|
* for the code under test.
|
|
*/
|
|
|
|
import { randomUUID } from "node:crypto";
|
|
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
import { mkdir } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import * as core from "@elizaos/core";
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
|
|
// getTraceUsage delegates to core's rollUpTrajectoryUsage. In an isolated
|
|
// worktree the plugin resolves core's PREBUILT dist, which is stale until
|
|
// turbo rebuilds it (CI does this before plugin tests); a namespace import
|
|
// yields `undefined` for a not-yet-built export rather than a load error, so
|
|
// guard on it and skip a stale-dist local run instead of red-ing. Same
|
|
// stale-dist guard rationale as child-trajectory-ingest.test.ts.
|
|
const describeCore =
|
|
typeof (core as { rollUpTrajectoryUsage?: unknown }).rollUpTrajectoryUsage ===
|
|
"function"
|
|
? describe
|
|
: describe.skip;
|
|
|
|
import { OrchestratorTaskService } from "../../src/services/orchestrator-task-service.js";
|
|
import { InMemoryTaskStore } from "../../src/services/orchestrator-task-store.js";
|
|
import type { OrchestratorTaskSession } from "../../src/services/orchestrator-task-types.js";
|
|
|
|
let stateDir: string;
|
|
const prevStateDir = process.env.ELIZA_STATE_DIR;
|
|
|
|
function makeRuntime() {
|
|
return {
|
|
agentId: "00000000-0000-4000-8000-000000000001",
|
|
adapter: undefined,
|
|
databaseAdapter: undefined,
|
|
logger: { debug() {}, info() {}, warn() {}, error() {} },
|
|
getSetting: () => undefined,
|
|
reportError: () => {},
|
|
} as unknown as Parameters<typeof OrchestratorTaskService>[0];
|
|
}
|
|
|
|
async function seedTaskWithSession(
|
|
store: InMemoryTaskStore,
|
|
session: Partial<OrchestratorTaskSession>,
|
|
): Promise<{ taskId: string; sessionId: string }> {
|
|
const doc = await store.createTask({ title: "t", goal: "do the thing" });
|
|
const taskId = doc.task.id;
|
|
const sessionId = "sess-1";
|
|
const now = new Date().toISOString();
|
|
await store.addSession({
|
|
id: "row-1",
|
|
taskId,
|
|
sessionId,
|
|
framework: "elizaos",
|
|
label: "worker",
|
|
originalTask: "do the thing",
|
|
workdir: "/tmp/wd",
|
|
status: "completed",
|
|
decisionCount: 0,
|
|
autoResolvedCount: 0,
|
|
registeredAt: Date.now(),
|
|
lastActivityAt: Date.now(),
|
|
idleCheckCount: 0,
|
|
taskDelivered: true,
|
|
lastSeenDecisionIndex: 0,
|
|
spawnedAt: Date.now(),
|
|
retryCount: 0,
|
|
inputTokens: 0,
|
|
outputTokens: 0,
|
|
reasoningTokens: 0,
|
|
cacheTokens: 0,
|
|
costUsd: 0,
|
|
usageState: "unavailable",
|
|
metadata: {},
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
...session,
|
|
});
|
|
return { taskId, sessionId };
|
|
}
|
|
|
|
function trajectoryJson(traceId: string, prompt: number, cost: number): string {
|
|
return JSON.stringify({
|
|
trajectoryId: `tj-${prompt}`,
|
|
agentId: "child-agent",
|
|
traceId,
|
|
rootMessage: { id: "m", text: "hi" },
|
|
startedAt: 0,
|
|
status: "finished",
|
|
stages: [],
|
|
metrics: {
|
|
totalLatencyMs: 0,
|
|
totalPromptTokens: prompt,
|
|
totalCompletionTokens: Math.floor(prompt / 5),
|
|
totalCacheReadTokens: 0,
|
|
totalCacheCreationTokens: 0,
|
|
totalCostUsd: cost,
|
|
plannerIterations: 0,
|
|
toolCallsExecuted: 0,
|
|
toolCallFailures: 0,
|
|
toolSearchCount: 0,
|
|
evaluatorFailures: 0,
|
|
},
|
|
});
|
|
}
|
|
|
|
beforeEach(() => {
|
|
stateDir = mkdtempSync(join(tmpdir(), "orch-trace-usage-"));
|
|
process.env.ELIZA_STATE_DIR = stateDir;
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (prevStateDir === undefined) delete process.env.ELIZA_STATE_DIR;
|
|
else process.env.ELIZA_STATE_DIR = prevStateDir;
|
|
});
|
|
|
|
async function ingest(
|
|
svc: OrchestratorTaskService,
|
|
taskId: string,
|
|
sessionId: string,
|
|
): Promise<string[]> {
|
|
return (
|
|
svc as unknown as {
|
|
ingestChildTrajectories: (t: string, s: string) => Promise<string[]>;
|
|
}
|
|
).ingestChildTrajectories(taskId, sessionId);
|
|
}
|
|
|
|
describeCore("getTraceUsage", () => {
|
|
it("sums the ingested child-trajectory metrics grouped by traceId", async () => {
|
|
const store = new InMemoryTaskStore();
|
|
const { taskId, sessionId } = await seedTaskWithSession(store, {
|
|
traceId: "trace-parent",
|
|
});
|
|
const svc = new OrchestratorTaskService(makeRuntime(), { store });
|
|
|
|
const dir = join(
|
|
stateDir,
|
|
"orchestrator",
|
|
"child-trajectories",
|
|
taskId,
|
|
"child-agent",
|
|
);
|
|
await mkdir(dir, { recursive: true });
|
|
writeFileSync(
|
|
join(dir, "tj-100.json"),
|
|
trajectoryJson("trace-parent", 100, 0.01),
|
|
);
|
|
writeFileSync(
|
|
join(dir, "tj-50.json"),
|
|
trajectoryJson("trace-parent", 50, 0.005),
|
|
);
|
|
|
|
await ingest(svc, taskId, sessionId);
|
|
const rollup = await svc.getTraceUsage(taskId);
|
|
expect(rollup).not.toBeNull();
|
|
expect(rollup?.readState).toBe("complete");
|
|
expect(rollup?.artifactErrors).toEqual([]);
|
|
expect(rollup?.artifactCount).toBe(2);
|
|
expect(rollup?.readableArtifactCount).toBe(2);
|
|
expect(rollup?.unreadableArtifactCount).toBe(0);
|
|
expect(rollup?.byTrace).toHaveLength(1);
|
|
expect(rollup?.byTrace[0].traceId).toBe("trace-parent");
|
|
expect(rollup?.promptTokens).toBe(150);
|
|
// completion = floor(100/5) + floor(50/5) = 20 + 10 = 30
|
|
expect(rollup?.completionTokens).toBe(30);
|
|
expect(rollup?.totalTokens).toBe(180);
|
|
expect(rollup?.trajectoryCount).toBe(2);
|
|
expect(rollup?.costUsd).toBeCloseTo(0.015, 6);
|
|
});
|
|
|
|
it("returns a partial roll-up when a corrupt artifact prevents complete usage accounting", async () => {
|
|
const store = new InMemoryTaskStore();
|
|
const { taskId, sessionId } = await seedTaskWithSession(store, {
|
|
traceId: "trace-parent",
|
|
});
|
|
const svc = new OrchestratorTaskService(makeRuntime(), { store });
|
|
|
|
const dir = join(
|
|
stateDir,
|
|
"orchestrator",
|
|
"child-trajectories",
|
|
taskId,
|
|
"child-agent",
|
|
);
|
|
await mkdir(dir, { recursive: true });
|
|
writeFileSync(
|
|
join(dir, "tj-good.json"),
|
|
trajectoryJson("trace-parent", 40, 0.004),
|
|
);
|
|
writeFileSync(join(dir, "tj-bad.json"), "{ this is not valid json");
|
|
|
|
await ingest(svc, taskId, sessionId);
|
|
const rollup = await svc.getTraceUsage(taskId);
|
|
expect(rollup?.promptTokens).toBe(40);
|
|
expect(rollup?.trajectoryCount).toBe(1);
|
|
expect(rollup?.costUsd).toBeCloseTo(0.004, 6);
|
|
expect(rollup?.readState).toBe("partial");
|
|
expect(rollup?.artifactCount).toBe(2);
|
|
expect(rollup?.readableArtifactCount).toBe(1);
|
|
expect(rollup?.unreadableArtifactCount).toBe(1);
|
|
expect(rollup?.artifactErrors).toEqual([
|
|
expect.objectContaining({
|
|
path: join(dir, "tj-bad.json"),
|
|
reason: "read_failed",
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("returns a partial roll-up when a trajectory artifact has no metrics block", async () => {
|
|
const store = new InMemoryTaskStore();
|
|
const { taskId, sessionId } = await seedTaskWithSession(store, {
|
|
traceId: "trace-parent",
|
|
});
|
|
const svc = new OrchestratorTaskService(makeRuntime(), { store });
|
|
|
|
const dir = join(
|
|
stateDir,
|
|
"orchestrator",
|
|
"child-trajectories",
|
|
taskId,
|
|
"child-agent",
|
|
);
|
|
await mkdir(dir, { recursive: true });
|
|
writeFileSync(
|
|
join(dir, "tj-good.json"),
|
|
trajectoryJson("trace-parent", 25, 0.0025),
|
|
);
|
|
writeFileSync(
|
|
join(dir, "tj-invalid.json"),
|
|
JSON.stringify({ trajectoryId: "tj-invalid", traceId: "trace-parent" }),
|
|
);
|
|
|
|
await ingest(svc, taskId, sessionId);
|
|
const rollup = await svc.getTraceUsage(taskId);
|
|
expect(rollup?.promptTokens).toBe(25);
|
|
expect(rollup?.readState).toBe("partial");
|
|
expect(rollup?.artifactCount).toBe(2);
|
|
expect(rollup?.readableArtifactCount).toBe(1);
|
|
expect(rollup?.unreadableArtifactCount).toBe(1);
|
|
expect(rollup?.artifactErrors).toEqual([
|
|
expect.objectContaining({
|
|
path: join(dir, "tj-invalid.json"),
|
|
reason: "invalid_trajectory",
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("returns an empty roll-up (not null) for a task with no trajectory artifacts", async () => {
|
|
const store = new InMemoryTaskStore();
|
|
const { taskId } = await seedTaskWithSession(store, {});
|
|
const svc = new OrchestratorTaskService(makeRuntime(), { store });
|
|
const rollup = await svc.getTraceUsage(taskId);
|
|
expect(rollup).not.toBeNull();
|
|
expect(rollup?.readState).toBe("complete");
|
|
expect(rollup?.artifactCount).toBe(0);
|
|
expect(rollup?.artifactErrors).toEqual([]);
|
|
expect(rollup?.byTrace).toEqual([]);
|
|
expect(rollup?.totalTokens).toBe(0);
|
|
expect(rollup?.trajectoryCount).toBe(0);
|
|
});
|
|
|
|
it("counts a trajectory file once even when duplicate artifact rows point at it (multi-session/retry rescan)", async () => {
|
|
const store = new InMemoryTaskStore();
|
|
const { taskId, sessionId } = await seedTaskWithSession(store, {
|
|
traceId: "trace-parent",
|
|
});
|
|
const svc = new OrchestratorTaskService(makeRuntime(), { store });
|
|
|
|
const dir = join(
|
|
stateDir,
|
|
"orchestrator",
|
|
"child-trajectories",
|
|
taskId,
|
|
"child-agent",
|
|
);
|
|
await mkdir(dir, { recursive: true });
|
|
const trajPath = join(dir, "tj-dup.json");
|
|
writeFileSync(trajPath, trajectoryJson("trace-parent", 80, 0.008));
|
|
|
|
// ingestChildTrajectories now path-dedupes at ingest time (#14110), so it
|
|
// attaches at most one row per file and a re-ingest is a no-op. getTraceUsage
|
|
// keeps its own path-dedupe to defend against duplicate rows that predate
|
|
// that fix (or a legacy multi-session rescan). Reproduce exactly that
|
|
// condition: ingest once for a genuine row, then attach a second row for the
|
|
// SAME file directly, the way a pre-dedupe rescan would have.
|
|
await ingest(svc, taskId, sessionId);
|
|
await store.addArtifact({
|
|
id: randomUUID(),
|
|
taskId,
|
|
sessionId,
|
|
artifactType: "trajectory",
|
|
title: "Sub-agent trajectory tj-dup (duplicate row)",
|
|
path: trajPath,
|
|
verificationStatus: "pending",
|
|
metadata: {},
|
|
createdAt: new Date().toISOString(),
|
|
});
|
|
const doc = await store.getTask(taskId);
|
|
const trajRows = (doc?.artifacts ?? []).filter(
|
|
(a) => a.artifactType === "trajectory",
|
|
);
|
|
expect(trajRows.length).toBeGreaterThan(1); // duplicate rows exist
|
|
|
|
const rollup = await svc.getTraceUsage(taskId);
|
|
// ...but the file's spend is counted exactly once.
|
|
expect(rollup?.readState).toBe("complete");
|
|
expect(rollup?.artifactCount).toBe(1);
|
|
expect(rollup?.readableArtifactCount).toBe(1);
|
|
expect(rollup?.promptTokens).toBe(80);
|
|
expect(rollup?.trajectoryCount).toBe(1);
|
|
expect(rollup?.costUsd).toBeCloseTo(0.008, 6);
|
|
});
|
|
|
|
it("returns null for an unknown task", async () => {
|
|
const store = new InMemoryTaskStore();
|
|
const svc = new OrchestratorTaskService(makeRuntime(), { store });
|
|
expect(await svc.getTraceUsage("nope")).toBeNull();
|
|
});
|
|
});
|