mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-10 12:07:09 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Paperclip records agent run outcomes through telemetry and run lifecycle services > - Terminal run transitions need one consistent event for outcome analysis > - The current paths do not report every terminal transition through one event > - This pull request adds the agent.task_run event and emits it at each terminal transition > - The benefit is complete run outcome data without exposing raw task identifiers ## Linked Issues or Issue Description **What existing behavior does this improve?** Paperclip telemetry reports agent activity, but it does not report every terminal task run through one event. **Subsystem affected** Cross-cutting (multiple of the above): packages/shared telemetry and server run lifecycle services. **Current behavior** Several run paths write a terminal status without a matching agent.task_run telemetry event. **Proposed behavior** Each terminal run transition emits one agent.task_run event. The event records the terminal state and uses the existing pseudonym helper for the optional task identifier. **Reason and benefit** Complete terminal-run data helps operators measure agent outcomes. The pseudonym helper prevents the raw task identifier from leaving the installation. **Breaking changes** None. The change adds an event and keeps existing event behavior compatible. ## What Changed - Add the agent.task_run telemetry contract and client helper. - Reuse the existing pseudonym helper for the task identifier. The helper hashes the identifier with a per-installation salt and returns 16 hexadecimal characters. The raw identifier never leaves the installation. Existing identifiers do not move. - Emit one event from each legacy, native, recovery, and issue terminal transition. - Keep emissions outside database transactions and make delivery best-effort. - Add regression tests for event shape, hashing, terminal transitions, and emission failures. - Document the event and its privacy rule in the telemetry data contract. ## Verification - `npx tsc --noEmit` in `server/` passes at the submitted commit. - The pull-request CI suite must pass. CI is the authority because local Vitest has a known dependency artifact. - The added regression tests cover event output shape, per-installation hash divergence, raw identifier handoff, omitted identifiers, and non-throwing emits. ## Risks - A missed terminal path could reduce event coverage. - Telemetry delivery remains best-effort and cannot change run finalization. - The pseudonym helper uses installation-specific state, so identifiers differ between installations. ## Model Used OpenAI Codex, GPT-5, tool use and code execution. Context window details were not provided. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes: #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I have addressed all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
292 lines
8.7 KiB
TypeScript
292 lines
8.7 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import { agents, companies, createDb, heartbeatRuns } from "@paperclipai/db";
|
|
import {
|
|
getEmbeddedPostgresTestSupport,
|
|
startEmbeddedPostgresTestDatabase,
|
|
} from "./helpers/embedded-postgres.js";
|
|
|
|
const mockTelemetryClient = vi.hoisted(() => ({ track: vi.fn() }));
|
|
const mockTrackAgentTaskRun = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock("../telemetry.js", () => ({
|
|
getTelemetryClient: () => mockTelemetryClient,
|
|
}));
|
|
|
|
vi.mock("@paperclipai/shared/telemetry", async () => {
|
|
const actual = await vi.importActual<typeof import("@paperclipai/shared/telemetry")>(
|
|
"@paperclipai/shared/telemetry",
|
|
);
|
|
return {
|
|
...actual,
|
|
trackAgentTaskRun: mockTrackAgentTaskRun,
|
|
};
|
|
});
|
|
|
|
import { emitAgentTaskRun } from "../services/agent-task-run-telemetry.ts";
|
|
|
|
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
|
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
|
|
|
describeEmbeddedPostgres("emitAgentTaskRun", () => {
|
|
let db!: ReturnType<typeof createDb>;
|
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
|
let companyId!: string;
|
|
let agentId!: string;
|
|
|
|
beforeAll(async () => {
|
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-agent-task-run-telemetry-");
|
|
db = createDb(tempDb.connectionString);
|
|
}, 20_000);
|
|
|
|
afterEach(async () => {
|
|
vi.clearAllMocks();
|
|
await db.delete(heartbeatRuns);
|
|
await db.delete(agents);
|
|
await db.delete(companies);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await tempDb?.cleanup();
|
|
});
|
|
|
|
async function seedCompanyAndAgent(
|
|
agentOverrides: Partial<typeof agents.$inferInsert> = {},
|
|
) {
|
|
companyId = randomUUID();
|
|
agentId = randomUUID();
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
defaultResponsibleUserId: "responsible-user",
|
|
});
|
|
await db.insert(agents).values({
|
|
id: agentId,
|
|
companyId,
|
|
name: "CodexCoder",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
...agentOverrides,
|
|
});
|
|
return { companyId, agentId };
|
|
}
|
|
|
|
it("emits one agent.task_run event for each of the five terminal states", async () => {
|
|
await seedCompanyAndAgent();
|
|
const states = ["succeeded", "interrupted", "failed", "cancelled", "timed_out"] as const;
|
|
for (const state of states) {
|
|
mockTrackAgentTaskRun.mockClear();
|
|
const runId = randomUUID();
|
|
const run = {
|
|
id: runId,
|
|
companyId,
|
|
agentId,
|
|
status: state,
|
|
startedAt: null,
|
|
finishedAt: null,
|
|
usageJson: null,
|
|
contextSnapshot: null,
|
|
} as unknown as typeof heartbeatRuns.$inferSelect;
|
|
|
|
await emitAgentTaskRun(db, run);
|
|
|
|
expect(mockTrackAgentTaskRun).toHaveBeenCalledTimes(1);
|
|
expect(mockTrackAgentTaskRun).toHaveBeenCalledWith(
|
|
mockTelemetryClient,
|
|
expect.objectContaining({ agentId, state }),
|
|
);
|
|
}
|
|
});
|
|
|
|
it("omits durationSeconds when the run has no startedAt", async () => {
|
|
await seedCompanyAndAgent();
|
|
const run = {
|
|
id: randomUUID(),
|
|
companyId,
|
|
agentId,
|
|
status: "succeeded",
|
|
startedAt: null,
|
|
finishedAt: null,
|
|
usageJson: null,
|
|
contextSnapshot: null,
|
|
} as unknown as typeof heartbeatRuns.$inferSelect;
|
|
|
|
await emitAgentTaskRun(db, run);
|
|
|
|
const dims = mockTrackAgentTaskRun.mock.calls[0][1];
|
|
expect(dims).not.toHaveProperty("durationSeconds");
|
|
});
|
|
|
|
it("sends whole seconds from startedAt to finishedAt", async () => {
|
|
await seedCompanyAndAgent();
|
|
const startedAt = new Date("2026-01-01T00:00:00.000Z");
|
|
const finishedAt = new Date("2026-01-01T00:00:07.400Z");
|
|
const run = {
|
|
id: randomUUID(),
|
|
companyId,
|
|
agentId,
|
|
status: "succeeded",
|
|
startedAt,
|
|
finishedAt,
|
|
usageJson: null,
|
|
contextSnapshot: null,
|
|
} as unknown as typeof heartbeatRuns.$inferSelect;
|
|
|
|
await emitAgentTaskRun(db, run);
|
|
|
|
const dims = mockTrackAgentTaskRun.mock.calls[0][1];
|
|
expect(dims.durationSeconds).toBe(7);
|
|
expect(Number.isInteger(dims.durationSeconds)).toBe(true);
|
|
});
|
|
|
|
it("never returns a negative durationSeconds when finishedAt precedes startedAt", async () => {
|
|
await seedCompanyAndAgent();
|
|
const startedAt = new Date("2026-01-01T00:00:10.000Z");
|
|
const finishedAt = new Date("2026-01-01T00:00:05.000Z");
|
|
const run = {
|
|
id: randomUUID(),
|
|
companyId,
|
|
agentId,
|
|
status: "cancelled",
|
|
startedAt,
|
|
finishedAt,
|
|
usageJson: null,
|
|
contextSnapshot: null,
|
|
} as unknown as typeof heartbeatRuns.$inferSelect;
|
|
|
|
await emitAgentTaskRun(db, run);
|
|
|
|
const dims = mockTrackAgentTaskRun.mock.calls[0][1];
|
|
expect(dims.durationSeconds).toBe(0);
|
|
});
|
|
|
|
it("omits model, the three token dimensions, and taskId when each source is absent", async () => {
|
|
await seedCompanyAndAgent({ adapterConfig: {} });
|
|
const run = {
|
|
id: randomUUID(),
|
|
companyId,
|
|
agentId,
|
|
status: "failed",
|
|
startedAt: null,
|
|
finishedAt: null,
|
|
usageJson: null,
|
|
contextSnapshot: null,
|
|
} as unknown as typeof heartbeatRuns.$inferSelect;
|
|
|
|
await emitAgentTaskRun(db, run);
|
|
|
|
const dims = mockTrackAgentTaskRun.mock.calls[0][1];
|
|
expect(dims).not.toHaveProperty("model");
|
|
expect(dims).not.toHaveProperty("inputTokens");
|
|
expect(dims).not.toHaveProperty("outputTokens");
|
|
expect(dims).not.toHaveProperty("cachedTokens");
|
|
expect(dims).not.toHaveProperty("taskId");
|
|
});
|
|
|
|
it("sends cachedTokens: 0 when usage exists and carries no cached value", async () => {
|
|
await seedCompanyAndAgent();
|
|
const run = {
|
|
id: randomUUID(),
|
|
companyId,
|
|
agentId,
|
|
status: "succeeded",
|
|
startedAt: null,
|
|
finishedAt: null,
|
|
usageJson: { inputTokens: 12, outputTokens: 34 },
|
|
contextSnapshot: null,
|
|
} as unknown as typeof heartbeatRuns.$inferSelect;
|
|
|
|
await emitAgentTaskRun(db, run);
|
|
|
|
const dims = mockTrackAgentTaskRun.mock.calls[0][1];
|
|
expect(dims.inputTokens).toBe(12);
|
|
expect(dims.outputTokens).toBe(34);
|
|
expect(dims.cachedTokens).toBe(0);
|
|
});
|
|
|
|
it("emits with no taskId when contextSnapshot carries no issueId", async () => {
|
|
await seedCompanyAndAgent();
|
|
const run = {
|
|
id: randomUUID(),
|
|
companyId,
|
|
agentId,
|
|
status: "succeeded",
|
|
startedAt: null,
|
|
finishedAt: null,
|
|
usageJson: null,
|
|
contextSnapshot: { wakeReason: "issue_commented" },
|
|
} as unknown as typeof heartbeatRuns.$inferSelect;
|
|
|
|
await emitAgentTaskRun(db, run);
|
|
|
|
const dims = mockTrackAgentTaskRun.mock.calls[0][1];
|
|
expect(dims).not.toHaveProperty("taskId");
|
|
});
|
|
|
|
it("passes the raw task identifier to trackAgentTaskRun without hashing it", async () => {
|
|
await seedCompanyAndAgent();
|
|
const rawIssueId = randomUUID();
|
|
const run = {
|
|
id: randomUUID(),
|
|
companyId,
|
|
agentId,
|
|
status: "succeeded",
|
|
startedAt: null,
|
|
finishedAt: null,
|
|
usageJson: null,
|
|
contextSnapshot: { issueId: rawIssueId },
|
|
} as unknown as typeof heartbeatRuns.$inferSelect;
|
|
|
|
await emitAgentTaskRun(db, run);
|
|
|
|
const dims = mockTrackAgentTaskRun.mock.calls[0][1];
|
|
expect(dims.taskId).toBe(rawIssueId);
|
|
expect(dims.taskId).not.toMatch(/^[0-9a-f]{32}$/);
|
|
});
|
|
|
|
it("does not throw out of the status write when the emit fails", async () => {
|
|
await seedCompanyAndAgent();
|
|
mockTrackAgentTaskRun.mockImplementationOnce(() => {
|
|
throw new Error("telemetry backend unreachable");
|
|
});
|
|
const run = {
|
|
id: randomUUID(),
|
|
companyId,
|
|
agentId,
|
|
status: "succeeded",
|
|
startedAt: null,
|
|
finishedAt: null,
|
|
usageJson: null,
|
|
contextSnapshot: null,
|
|
} as unknown as typeof heartbeatRuns.$inferSelect;
|
|
|
|
await expect(emitAgentTaskRun(db, run)).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("omits adapterType and agentRole when the agent row is absent", async () => {
|
|
await seedCompanyAndAgent();
|
|
const run = {
|
|
id: randomUUID(),
|
|
companyId,
|
|
agentId: randomUUID(),
|
|
status: "succeeded",
|
|
startedAt: null,
|
|
finishedAt: null,
|
|
usageJson: null,
|
|
contextSnapshot: null,
|
|
} as unknown as typeof heartbeatRuns.$inferSelect;
|
|
|
|
await emitAgentTaskRun(db, run);
|
|
|
|
const dims = mockTrackAgentTaskRun.mock.calls[0][1];
|
|
expect(dims).not.toHaveProperty("adapterType");
|
|
expect(dims).not.toHaveProperty("agentRole");
|
|
});
|
|
});
|