Compare commits

...
Author SHA1 Message Date
nexxeln 231e85328b fix(core): count orphaned fork usage in stats 2026-09-10 10:12:30 +00:00
2 changed files with 80 additions and 4 deletions
+12 -4
View File
@@ -96,7 +96,9 @@ export const get = Effect.fn("SessionStats.get")(function* (input: Input = {}) {
JOIN ${SessionTable} AS session ON session.id = message.session_id
WHERE message.type IN ('user', 'assistant')
AND message.time_created < ${to}
AND (session.fork_session_id IS NULL OR message.time_created >= session.time_created)
AND (session.fork_session_id IS NULL
OR NOT EXISTS (SELECT 1 FROM ${SessionTable} AS parent WHERE parent.id = session.fork_session_id)
OR message.time_created >= session.time_created)
${project}
`,
)
@@ -143,7 +145,9 @@ export const get = Effect.fn("SessionStats.get")(function* (input: Input = {}) {
WHERE message.type IN ('user', 'assistant')
AND message.time_created >= ${range.from}
AND message.time_created < ${range.to}
AND (session.fork_session_id IS NULL OR message.time_created >= session.time_created)
AND (session.fork_session_id IS NULL
OR NOT EXISTS (SELECT 1 FROM ${SessionTable} AS parent WHERE parent.id = session.fork_session_id)
OR message.time_created >= session.time_created)
${project}
`,
)
@@ -204,7 +208,9 @@ export const get = Effect.fn("SessionStats.get")(function* (input: Input = {}) {
WHERE message.type = 'assistant'
AND message.time_created >= ${range.from}
AND message.time_created < ${range.to}
AND (session.fork_session_id IS NULL OR message.time_created >= session.time_created)
AND (session.fork_session_id IS NULL
OR NOT EXISTS (SELECT 1 FROM ${SessionTable} AS parent WHERE parent.id = session.fork_session_id)
OR message.time_created >= session.time_created)
AND json_extract(content.value, '$.type') = 'tool'
${project}
)
@@ -246,7 +252,9 @@ export const get = Effect.fn("SessionStats.get")(function* (input: Input = {}) {
WHERE message.type = 'assistant'
AND message.time_created >= ${range.from}
AND message.time_created < ${range.to}
AND (session.fork_session_id IS NULL OR message.time_created >= session.time_created)
AND (session.fork_session_id IS NULL
OR NOT EXISTS (SELECT 1 FROM ${SessionTable} AS parent WHERE parent.id = session.fork_session_id)
OR message.time_created >= session.time_created)
AND json_extract(content.value, '$.type') = 'tool'
${project}
`,
+68
View File
@@ -16,6 +16,7 @@ import { SessionMessageTable, SessionTable } from "@opencode/core/session/sql"
import { SessionStats } from "@opencode/core/session/stats"
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
import { DateTime, Effect, Schema } from "effect"
import { eq } from "drizzle-orm"
import { testEffect } from "./lib/effect"
const it = testEffect(AppNodeBuilder.build(Database.node))
@@ -30,6 +31,73 @@ const encodeMessage = Schema.encodeSync(SessionMessage.Info)
const encodeUsage = Schema.encodeSync(SessionEvent.UsageRecorded.data)
describe("SessionStats", () => {
it.effect("counts inherited fork usage after the parent is deleted", () =>
Effect.gen(function* () {
const db = (yield* Database.Service).db
const parentID = Session.ID.make("ses_stats_deleted_parent")
const orphanID = Session.ID.make("ses_stats_orphan_fork")
yield* db
.insert(ProjectTable)
.values({ id: projectID, worktree: AbsolutePath.make("/stats"), name: "stats", sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values([
{ id: parentID, project_id: projectID, slug: "parent", directory: "/stats", version: "test" },
{
id: orphanID,
project_id: projectID,
fork_session_id: parentID,
slug: "fork",
directory: "/stats",
version: "test",
time_created: Date.UTC(2026, 0, 4),
},
])
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionMessageTable)
.values([
messageRow(parentID, 1, assistant("msg_stats_deleted_parent", Date.UTC(2026, 0, 2, 10), [])),
messageRow(
orphanID,
1,
assistant("msg_stats_orphan_inherited", Date.UTC(2026, 0, 2, 10), [
SessionMessage.AssistantTool.make({
type: "tool",
id: "call_inherited",
name: "read",
state: SessionMessage.ToolStateCompleted.make({
status: "completed",
input: {},
content: [{ type: "text", text: "ok" }],
}),
time: { created: DateTime.makeUnsafe(Date.UTC(2026, 0, 2, 10)) },
}),
]),
),
messageRow(orphanID, 2, assistant("msg_stats_orphan_new", Date.UTC(2026, 0, 5, 10), [])),
])
.run()
.pipe(Effect.orDie)
yield* db.delete(SessionTable).where(eq(SessionTable.id, parentID)).run().pipe(Effect.orDie)
const stats = yield* SessionStats.get({ to: Date.UTC(2026, 1, 1), timezone: "UTC", tools: "detail" })
expect(DateTime.toEpochMillis(stats.range.from)).toBe(Date.UTC(2026, 0, 2, 10))
expect(stats.steps).toBe(2)
expect(stats.tokens).toEqual({ input: 20, output: 10, reasoning: 4, cache: { read: 8, write: 2 } })
expect(stats.cost).toBe(Money.USD.make(3))
expect(stats.tools).toMatchObject({
mode: "detail",
totals: { calls: 1, succeeded: 1, failed: 0, unfinished: 0 },
})
}),
)
it.effect("aggregates activity and tool reliability without reading message payloads outside the range", () =>
Effect.gen(function* () {
const db = (yield* Database.Service).db