@ymys4ever/opencode-throughput-v2 4.0.1 → 4.0.2

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.
package/src/benchmark.ts DELETED
@@ -1,112 +0,0 @@
1
- import type { LogEntry } from "./types.js"
2
- import { aggregateAt } from "./core/stats.js"
3
- import { formatMs, formatNum } from "./format/entry.js"
4
- import { createLogStore, type LogStore } from "./io/log-store.js"
5
-
6
- export interface BenchmarkDeps {
7
- logStore?: LogStore
8
- }
9
-
10
- export async function executeBenchmark(
11
- argsRaw: unknown,
12
- deps?: BenchmarkDeps
13
- ): Promise<{ content: string }> {
14
- const args = (argsRaw ?? {}) as { model?: string; last?: number }
15
- const store = deps?.logStore ?? createLogStore()
16
-
17
- let entries: LogEntry[]
18
- let skipped = 0
19
- try {
20
- const read = store.read()
21
- entries = read.entries
22
- skipped = read.skipped
23
- } catch (e) {
24
- return {
25
- content: `Failed to read log file: ${e instanceof Error ? e.message : String(e)}`,
26
- }
27
- }
28
-
29
- if (entries.length === 0) {
30
- return {
31
- content:
32
- "No benchmark data yet. The throughput plugin needs to process at least one LLM response before stats are available.",
33
- }
34
- }
35
-
36
- const keyword = args.model?.toLowerCase()
37
- const filtered = keyword
38
- ? entries.filter(
39
- (l: LogEntry) =>
40
- l.model.toLowerCase().includes(keyword) ||
41
- l.modelID.toLowerCase().includes(keyword)
42
- )
43
- : entries
44
-
45
- if (keyword && filtered.length === 0) {
46
- return { content: `No entries match filter: ${args.model}` }
47
- }
48
-
49
- // Defensive last: only a positive integer is honored, otherwise ignored.
50
- const last =
51
- args.last !== undefined && Number.isInteger(args.last) && args.last >= 1 ? args.last : undefined
52
- const sliced = last ? filtered.slice(-last) : filtered
53
-
54
- const byModel = new Map<string, LogEntry[]>()
55
- for (const entry of sliced) {
56
- const arr = byModel.get(entry.model) ?? []
57
- arr.push(entry)
58
- byModel.set(entry.model, arr)
59
- }
60
-
61
- const statsByModel = aggregateAt(sliced, Date.now())
62
-
63
- const lines: string[] = []
64
- lines.push(`## Model Performance Benchmark`)
65
- lines.push(`Total requests: ${sliced.length}`)
66
- if (args.model) lines.push(`Filter: ${args.model}`)
67
- lines.push(``)
68
-
69
- for (const [model, modelEntries] of byModel) {
70
- const s = statsByModel.get(model)
71
- if (!s) continue
72
- lines.push(`### ${model}`)
73
- lines.push(` Requests: ${modelEntries.length}`)
74
- lines.push(``)
75
- lines.push(` **Latency:**`)
76
- lines.push(
77
- ` TTFT: avg ${formatMs(s.avgTTFT)} | min ${formatMs(s.minTTFT)} | max ${formatMs(s.maxTTFT)}`
78
- )
79
- lines.push(
80
- ` Total: avg ${formatMs(s.avgLatency)} | min ${formatMs(s.minLatency)} | max ${formatMs(s.maxLatency)}`
81
- )
82
- lines.push(``)
83
- lines.push(` **Throughput:**`)
84
- lines.push(` TPS: avg ${formatNum(s.avgTPS)} | max ${formatNum(s.maxTPS)}`)
85
- lines.push(``)
86
- lines.push(` **Tokens:**`)
87
- lines.push(` Input: ${formatNum(s.totalInputTokens)}`)
88
- lines.push(` Output: ${formatNum(s.totalOutputTokens)}`)
89
- lines.push(` Reasoning: ${formatNum(s.totalReasoningTokens)}`)
90
- lines.push(` Cache R/W: ${formatNum(s.totalCacheReadTokens)} / ${formatNum(s.totalCacheWriteTokens)}`)
91
- lines.push(``)
92
- lines.push(` **Cost:** $${s.totalCost.toFixed(4)}`)
93
-
94
- if (modelEntries.length <= 5) {
95
- lines.push(``)
96
- lines.push(` Recent entries:`)
97
- for (const e of modelEntries) {
98
- lines.push(
99
- ` ${e.ts} | ${formatMs(e.ttft_ms)} TTFT | ${formatNum(e.tps)} tok/s | ${formatMs(e.latency_ms)} | cost $${e.cost.toFixed(4)}`
100
- )
101
- }
102
- }
103
-
104
- lines.push(``)
105
- }
106
-
107
- if (skipped > 0) {
108
- lines.push(`(${skipped} corrupt lines ignored)`)
109
- }
110
-
111
- return { content: lines.join("\n") }
112
- }
@@ -1,141 +0,0 @@
1
- import type { StepTracker, TerminalStepData } from "./step-tracker.js"
2
- import type { LogEntry } from "../types.js"
3
-
4
- /**
5
- * Event mapping for the throughput plugin — the single source of truth for
6
- * which session events are monitored and how each mutates the StepTracker.
7
- *
8
- * Type derivation strategy: the SDK's session-event union (V2Event and its
9
- * SessionStepStarted/SessionTextStarted/... members) lives in generated
10
- * types inside @opencode/client, a transitive dependency, and is not
11
- * re-exported from the @opencode/plugin public entry. A Pick/Extract
12
- * derivation would pin deep internal SDK paths, so ThroughputEvent below is
13
- * a hand-written minimal structural union mirroring the SDK shapes,
14
- * restricted to the fields this plugin actually consumes:
15
- * type / created / location.directory / data.sessionID /
16
- * data.assistantMessageID / data.model.{id,providerID} / data.started /
17
- * data.finish / data.cost / data.tokens.
18
- */
19
-
20
- export type ThroughputEvent =
21
- | {
22
- type: "session.step.started"
23
- created: number
24
- location?: { directory?: string }
25
- data: {
26
- sessionID: string
27
- assistantMessageID: string
28
- model: { id: string; providerID: string }
29
- started: number
30
- }
31
- }
32
- | {
33
- type: "session.text.started"
34
- created: number
35
- location?: { directory?: string }
36
- data: { sessionID: string; assistantMessageID: string }
37
- }
38
- | {
39
- type: "session.reasoning.started"
40
- created: number
41
- location?: { directory?: string }
42
- data: { sessionID: string; assistantMessageID: string }
43
- }
44
- | {
45
- type: "session.step.streamed"
46
- created: number
47
- location?: { directory?: string }
48
- data: { sessionID: string; assistantMessageID: string }
49
- }
50
- | {
51
- type: "session.step.ended"
52
- created: number
53
- location?: { directory?: string }
54
- data: TerminalStepData
55
- }
56
- | {
57
- type: "session.step.failed"
58
- created: number
59
- location?: { directory?: string }
60
- data: TerminalStepData
61
- }
62
- | {
63
- type: "session.idle"
64
- created: number
65
- location?: { directory?: string }
66
- data: { sessionID: string }
67
- }
68
-
69
- /** Exactly the event types both entrypoints subscribe to. */
70
- export const SUBSCRIBED_EVENT_TYPES: readonly string[] = [
71
- "session.step.started",
72
- "session.text.started",
73
- "session.reasoning.started",
74
- "session.step.streamed",
75
- "session.step.ended",
76
- "session.step.failed",
77
- "session.idle",
78
- ] as const satisfies readonly ThroughputEvent["type"][]
79
-
80
- /**
81
- * normalizeEvent outcome. `malformed` is reserved for a future payload
82
- * pre-validation pass and is never produced today: known-type events are
83
- * passed through unvalidated (see normalizeEvent).
84
- */
85
- export type NormalizedEvent =
86
- | { ok: true; event: ThroughputEvent }
87
- | { ok: false; reason: "unknown" | "malformed" }
88
-
89
- /**
90
- * Dual-channel classification, NO payload validation (current semantics):
91
- * a known event type passes through as-is — a malformed payload throws
92
- * naturally inside applyThroughputEvent's property accesses and is caught
93
- * by the existing per-event onError boundary; anything else (unknown type,
94
- * non-object, missing type) is silently skipped, matching the previous
95
- * switch fall-through.
96
- */
97
- export function normalizeEvent(raw: unknown): NormalizedEvent {
98
- if (typeof raw === "object" && raw != null && "type" in raw) {
99
- const type = (raw as { type: unknown }).type
100
- if (typeof type === "string" && SUBSCRIBED_EVENT_TYPES.includes(type)) {
101
- return { ok: true, event: raw as ThroughputEvent }
102
- }
103
- }
104
- return { ok: false, reason: "unknown" }
105
- }
106
-
107
- /**
108
- * Apply one known event to the tracker; returns the settled LogEntry for
109
- * terminal events (step.ended/failed), else null. Verbatim move of the
110
- * event switch previously duplicated in plugins/throughput.ts and
111
- * tui/controller.ts.
112
- */
113
- export function applyThroughputEvent(
114
- tracker: StepTracker,
115
- ev: ThroughputEvent,
116
- deps: { recordedAt: number; abortTtlMs: number }
117
- ): LogEntry | null {
118
- switch (ev.type) {
119
- case "session.step.started":
120
- tracker.start(ev.data)
121
- return null
122
- case "session.text.started":
123
- case "session.reasoning.started":
124
- // Reasoning often streams before text; the first one wins.
125
- tracker.markFirstOutput(ev.data.assistantMessageID, ev.created)
126
- return null
127
- // streamed = provider body fully consumed: the best TPS end boundary (tool time excluded); absent on older hosts -> terminal-event boundary (see buildLogEntry).
128
- case "session.step.streamed":
129
- tracker.markStreamed(ev.data.assistantMessageID, ev.created)
130
- return null
131
- case "session.step.ended":
132
- case "session.step.failed":
133
- return tracker.settle(ev.data.assistantMessageID, ev.created, ev.data, deps.recordedAt)
134
- case "session.idle":
135
- // Park instead of dropping so late terminal events can still settle.
136
- tracker.parkSession(ev.data.sessionID, ev.created, deps.abortTtlMs)
137
- return null
138
- }
139
- // Defensive only: unreachable for events that passed normalizeEvent.
140
- return null
141
- }
@@ -1,90 +0,0 @@
1
- import type { LogEntry } from "../types.js"
2
-
3
- function isString(v: unknown): v is string {
4
- return typeof v === "string"
5
- }
6
-
7
- function isFiniteNumber(v: unknown): v is number {
8
- return typeof v === "number" && Number.isFinite(v)
9
- }
10
-
11
- /**
12
- * Decode one JSONL line into a LogEntry, or null when the line is corrupt.
13
- * Hard violations (-> null): non-string model/providerID/modelID/sessionID/messageID,
14
- * or a present-but-wrong-typed metric (ttft_ms/tps/latency_ms) or amount
15
- * (tokens/cost). Coercions: missing metrics -> null, missing amounts -> 0;
16
- * missing/non-string finish -> "error", missing ts -> "".
17
- */
18
- export function decodeLogLine(line: string): LogEntry | null {
19
- let raw: unknown
20
- try {
21
- raw = JSON.parse(line)
22
- } catch {
23
- return null
24
- }
25
- if (typeof raw !== "object" || raw == null) return null
26
- const o = raw as Record<string, unknown>
27
-
28
- if (
29
- !isString(o.model) ||
30
- !isString(o.providerID) ||
31
- !isString(o.modelID) ||
32
- !isString(o.sessionID) ||
33
- !isString(o.messageID)
34
- ) {
35
- return null
36
- }
37
-
38
- const metrics = [o.ttft_ms, o.tps, o.latency_ms]
39
- const amounts = [
40
- o.inputTokens,
41
- o.outputTokens,
42
- o.reasoningTokens,
43
- o.cacheReadTokens,
44
- o.cacheWriteTokens,
45
- o.cost,
46
- ]
47
- const validMetric = (v: unknown): boolean => v == null || isFiniteNumber(v)
48
- if (!metrics.every(validMetric) || !amounts.every(validMetric)) return null
49
-
50
- // Coercion helpers: absence -> metric null / amount 0; validity checked above.
51
- const metric = (v: unknown): number | null => (isFiniteNumber(v) ? v : null)
52
- const amount = (v: unknown): number => (isFiniteNumber(v) ? v : 0)
53
-
54
- return {
55
- ts: isString(o.ts) ? o.ts : "",
56
- model: o.model,
57
- providerID: o.providerID,
58
- modelID: o.modelID,
59
- sessionID: o.sessionID,
60
- messageID: o.messageID,
61
- ttft_ms: metric(o.ttft_ms),
62
- tps: metric(o.tps),
63
- latency_ms: metric(o.latency_ms),
64
- inputTokens: amount(o.inputTokens),
65
- outputTokens: amount(o.outputTokens),
66
- reasoningTokens: amount(o.reasoningTokens),
67
- cacheReadTokens: amount(o.cacheReadTokens),
68
- cacheWriteTokens: amount(o.cacheWriteTokens),
69
- cost: amount(o.cost),
70
- finish: isString(o.finish) ? o.finish : "error",
71
- }
72
- }
73
-
74
- /** Decode full JSONL content; corrupt non-blank lines are counted, not fatal. */
75
- export function decodeLogContent(content: string): { entries: LogEntry[]; skipped: number } {
76
- const entries: LogEntry[] = []
77
- let skipped = 0
78
- for (const line of content.split("\n")) {
79
- if (!line.trim()) continue
80
- const entry = decodeLogLine(line)
81
- if (entry) entries.push(entry)
82
- else skipped++
83
- }
84
- return { entries, skipped }
85
- }
86
-
87
- /** Encode one entry as a JSONL line (trailing newline included). */
88
- export function encodeLogEntry(entry: LogEntry): string {
89
- return JSON.stringify(entry) + "\n"
90
- }
@@ -1,13 +0,0 @@
1
- export type LocationLike = { location?: { directory?: string } }
2
-
3
- /**
4
- * Unified location-ownership policy for both entrypoints.
5
- * Loose semantics: dir == null || selfDir == null || dir === selfDir.
6
- * TODO: tighten `dir == null` to false once host broadcast behavior is verified.
7
- */
8
- export function makeOwns(selfDir: string | undefined): (ev: LocationLike) => boolean {
9
- return (ev) => {
10
- const dir = ev.location?.directory
11
- return dir == null || selfDir == null || dir === selfDir
12
- }
13
- }
package/src/core/stats.ts DELETED
@@ -1,105 +0,0 @@
1
- import type { LogEntry, ModelStats } from "../types.js"
2
-
3
- /**
4
- * Single source of truth for aggregation. One-pass fold; null metrics are
5
- * skipped (never 0-filled); no samples -> null (never Infinity). `now` is
6
- * stamped into every row's lastUpdated — the caller owns the clock, so this
7
- * module never reads the wall clock.
8
- */
9
- export function aggregateAt(entries: readonly LogEntry[], now: number): Map<string, ModelStats> {
10
- const groups = new Map<string, GroupFold>()
11
- for (const entry of entries) {
12
- let g = groups.get(entry.model)
13
- if (!g) {
14
- g = {
15
- count: 0,
16
- ttft: newMetricFold(),
17
- tps: newMetricFold(),
18
- latency: newMetricFold(),
19
- totalInputTokens: 0,
20
- totalOutputTokens: 0,
21
- totalReasoningTokens: 0,
22
- totalCacheReadTokens: 0,
23
- totalCacheWriteTokens: 0,
24
- totalCost: 0,
25
- }
26
- groups.set(entry.model, g)
27
- }
28
- g.count++
29
- // Each metric is folded independently; null samples are skipped entirely.
30
- if (entry.ttft_ms != null) foldMetric(g.ttft, entry.ttft_ms)
31
- if (entry.tps != null) foldMetric(g.tps, entry.tps)
32
- if (entry.latency_ms != null) foldMetric(g.latency, entry.latency_ms)
33
- // Token/cost fields are always non-null on disk; plain sums.
34
- g.totalInputTokens += entry.inputTokens
35
- g.totalOutputTokens += entry.outputTokens
36
- g.totalReasoningTokens += entry.reasoningTokens
37
- g.totalCacheReadTokens += entry.cacheReadTokens
38
- g.totalCacheWriteTokens += entry.cacheWriteTokens
39
- g.totalCost += entry.cost
40
- }
41
-
42
- const result = new Map<string, ModelStats>()
43
- for (const [model, g] of groups) {
44
- result.set(model, {
45
- model,
46
- count: g.count,
47
- avgTTFT: metricAvg(g.ttft),
48
- minTTFT: metricMin(g.ttft),
49
- maxTTFT: metricMax(g.ttft),
50
- avgTPS: metricAvg(g.tps),
51
- maxTPS: metricMax(g.tps),
52
- avgLatency: metricAvg(g.latency),
53
- minLatency: metricMin(g.latency),
54
- maxLatency: metricMax(g.latency),
55
- totalInputTokens: g.totalInputTokens,
56
- totalOutputTokens: g.totalOutputTokens,
57
- totalReasoningTokens: g.totalReasoningTokens,
58
- totalCacheReadTokens: g.totalCacheReadTokens,
59
- totalCacheWriteTokens: g.totalCacheWriteTokens,
60
- totalCost: g.totalCost,
61
- lastUpdated: now,
62
- })
63
- }
64
- return result
65
- }
66
-
67
- /** Running fold for one metric: sum/count/min/max tracked in a single pass. */
68
- type MetricFold = { sum: number; count: number; min: number; max: number }
69
-
70
- type GroupFold = {
71
- count: number
72
- ttft: MetricFold
73
- tps: MetricFold
74
- latency: MetricFold
75
- totalInputTokens: number
76
- totalOutputTokens: number
77
- totalReasoningTokens: number
78
- totalCacheReadTokens: number
79
- totalCacheWriteTokens: number
80
- totalCost: number
81
- }
82
-
83
- function newMetricFold(): MetricFold {
84
- // min/max are only meaningful once count > 0; every read is guarded by count.
85
- return { sum: 0, count: 0, min: Infinity, max: -Infinity }
86
- }
87
-
88
- function foldMetric(m: MetricFold, v: number): void {
89
- m.sum += v
90
- m.count++
91
- if (v < m.min) m.min = v
92
- if (v > m.max) m.max = v
93
- }
94
-
95
- function metricAvg(m: MetricFold): number | null {
96
- return m.count > 0 ? m.sum / m.count : null
97
- }
98
-
99
- function metricMin(m: MetricFold): number | null {
100
- return m.count > 0 ? m.min : null
101
- }
102
-
103
- function metricMax(m: MetricFold): number | null {
104
- return m.count > 0 ? m.max : null
105
- }
@@ -1,204 +0,0 @@
1
- import type { LogEntry } from "../types.js"
2
-
3
- /** Canonical `providerID/modelID` key used to group entries per model. */
4
- export function getModelKey(providerID: string, modelID: string): string {
5
- return `${providerID}/${modelID}`
6
- }
7
-
8
- // In-flight state for one assistant step, keyed externally by assistantMessageID.
9
- export type StepState = {
10
- sessionID: string
11
- started: number
12
- providerID: string
13
- modelID: string
14
- modelKey: string
15
- // Event time (ms epoch) of the first streamed text/reasoning output, if any.
16
- firstOutput: number | null
17
- // session.step.streamed time; null => TPS denominator falls back to terminal time.
18
- streamEnded: number | null
19
- }
20
-
21
- export const ZERO_TOKENS = {
22
- input: 0,
23
- output: 0,
24
- reasoning: 0,
25
- cache: { read: 0, write: 0 },
26
- }
27
-
28
- export type TerminalStepData = {
29
- sessionID: string
30
- assistantMessageID: string
31
- finish?: string
32
- cost?: number
33
- tokens?: { input: number; output: number; reasoning: number; cache: { read: number; write: number } }
34
- }
35
-
36
- /**
37
- * TPS rule: genEnd = streamEnded ?? eventAt; with firstOutput:
38
- * output/(genEnd - firstOutput)*1000; without firstOutput the full step span
39
- * approximates generation: output/(eventAt - started)*1000.
40
- * output <= 0 or genTime <= 0 -> null. ttft = firstOutput - started | null.
41
- * @param eventAt event time (ms epoch) of the terminal event; drives every metric.
42
- * @param recordedAt clock reading (ms epoch) taken when the entry is recorded;
43
- * only `ts` derives from it (new Date(recordedAt).toISOString()), keeping
44
- * this module free of wall-clock reads.
45
- */
46
- export function buildLogEntry(
47
- state: StepState,
48
- eventAt: number,
49
- terminal: TerminalStepData,
50
- recordedAt: number
51
- ): LogEntry {
52
- const tokens = terminal.tokens ?? ZERO_TOKENS
53
- const latencyMs = eventAt - state.started
54
- const ttftMs = state.firstOutput != null ? state.firstOutput - state.started : null
55
-
56
- const genEnd = state.streamEnded ?? eventAt
57
- const genTimeMs = state.firstOutput != null ? genEnd - state.firstOutput : eventAt - state.started
58
- const tps = tokens.output > 0 && genTimeMs > 0 ? (tokens.output / genTimeMs) * 1000 : null
59
-
60
- return {
61
- ts: new Date(recordedAt).toISOString(),
62
- model: state.modelKey,
63
- providerID: state.providerID,
64
- modelID: state.modelID,
65
- sessionID: terminal.sessionID,
66
- messageID: terminal.assistantMessageID,
67
- ttft_ms: ttftMs,
68
- tps,
69
- latency_ms: latencyMs,
70
- inputTokens: tokens.input,
71
- outputTokens: tokens.output,
72
- reasoningTokens: tokens.reasoning,
73
- cacheReadTokens: tokens.cache.read,
74
- cacheWriteTokens: tokens.cache.write,
75
- cost: terminal.cost ?? 0,
76
- finish: terminal.finish ?? "error",
77
- }
78
- }
79
-
80
- /** Entry for a step that never reached a terminal event (parked and expired).
81
- * `now` serves as both the event time (metrics) and the record time (ts). */
82
- export function buildAbortedEntry(state: StepState, assistantMessageID: string, now: number): LogEntry {
83
- return buildLogEntry(state, now, { sessionID: state.sessionID, assistantMessageID, finish: "aborted" }, now)
84
- }
85
-
86
- /** In-flight state machine (no timers, no IO); parking = grace for late terminal events after session.idle.
87
- * Active steps additionally carry a TTL: a step whose terminal event AND
88
- * session.idle never arrive is evicted (recorded as finish:"aborted") once
89
- * `now - started >= activeTtlMs`, exactly like a parked timeout. */
90
- export class StepTracker {
91
- private active = new Map<string, StepState>()
92
- private parked = new Map<string, { state: StepState; deadline: number }>()
93
- private readonly activeTtlMs: number
94
-
95
- /** @param options.activeTtlMs eviction window for active steps (default 600_000 = 10 min);
96
- * 0 or negative disables active eviction entirely. */
97
- constructor(options: { activeTtlMs?: number } = {}) {
98
- const ttl = options.activeTtlMs ?? 600_000
99
- this.activeTtlMs = ttl > 0 ? ttl : 0
100
- }
101
-
102
- start(data: {
103
- sessionID: string
104
- assistantMessageID: string
105
- started: number
106
- model: { id: string; providerID: string }
107
- }): void {
108
- this.active.set(data.assistantMessageID, {
109
- sessionID: data.sessionID,
110
- started: data.started,
111
- providerID: data.model.providerID,
112
- modelID: data.model.id,
113
- modelKey: getModelKey(data.model.providerID, data.model.id),
114
- firstOutput: null,
115
- streamEnded: null,
116
- })
117
- }
118
-
119
- /** Idempotent; the first output event wins. */
120
- markFirstOutput(assistantMessageID: string, created: number): void {
121
- const state = this.lookup(assistantMessageID)
122
- if (state && state.firstOutput == null) state.firstOutput = created
123
- }
124
-
125
- /** Idempotent; the first streamed event wins. */
126
- markStreamed(assistantMessageID: string, created: number): void {
127
- const state = this.lookup(assistantMessageID)
128
- if (state && state.streamEnded == null) state.streamEnded = created
129
- }
130
-
131
- /** Removes state and builds the final entry; unknown id -> null.
132
- * Checks active first, then parked (a late ended after idle lands normally).
133
- * `eventAt` drives the metrics; `recordedAt` (record time) drives ts. */
134
- settle(assistantMessageID: string, eventAt: number, terminal: TerminalStepData, recordedAt: number): LogEntry | null {
135
- const activeState = this.active.get(assistantMessageID)
136
- if (activeState) {
137
- this.active.delete(assistantMessageID)
138
- return buildLogEntry(activeState, eventAt, terminal, recordedAt)
139
- }
140
- const parked = this.parked.get(assistantMessageID)
141
- if (parked) {
142
- this.parked.delete(assistantMessageID)
143
- return buildLogEntry(parked.state, eventAt, terminal, recordedAt)
144
- }
145
- return null
146
- }
147
-
148
- /** Move a session's in-flight steps to parking with deadline = now + ttlMs. */
149
- parkSession(sessionID: string, now: number, ttlMs: number): void {
150
- const deadline = now + ttlMs
151
- for (const [id, state] of this.active) {
152
- if (state.sessionID === sessionID) {
153
- this.parked.set(id, { state, deadline })
154
- this.active.delete(id)
155
- }
156
- }
157
- }
158
-
159
- /** Remove and return steps whose deadline has passed: parked steps past
160
- * their parking deadline, and active steps past their TTL (same
161
- * semantics — the caller records both as finish:"aborted" via
162
- * buildAbortedEntry). Source is not distinguished in the result. */
163
- collectExpired(now: number): Array<{ assistantMessageID: string; state: StepState }> {
164
- const expired: Array<{ assistantMessageID: string; state: StepState }> = []
165
- for (const [id, parked] of this.parked) {
166
- if (parked.deadline <= now) {
167
- expired.push({ assistantMessageID: id, state: parked.state })
168
- this.parked.delete(id)
169
- }
170
- }
171
- if (this.activeTtlMs > 0) {
172
- for (const [id, state] of this.active) {
173
- if (now - state.started >= this.activeTtlMs) {
174
- expired.push({ assistantMessageID: id, state })
175
- this.active.delete(id)
176
- }
177
- }
178
- }
179
- return expired
180
- }
181
-
182
- /** Earliest deadline among parked steps and active TTL expirations
183
- * (started + activeTtlMs); null when nothing is scheduled. Feeds the
184
- * pipeline's single flush timer, which then covers both sources. */
185
- nextDeadline(): number | null {
186
- let next: number | null = null
187
- const consider = (deadline: number): void => {
188
- if (next == null || deadline < next) next = deadline
189
- }
190
- for (const { deadline } of this.parked.values()) consider(deadline)
191
- if (this.activeTtlMs > 0) {
192
- for (const { started } of this.active.values()) consider(started + this.activeTtlMs)
193
- }
194
- return next
195
- }
196
-
197
- get size(): number {
198
- return this.active.size + this.parked.size
199
- }
200
-
201
- private lookup(assistantMessageID: string): StepState | undefined {
202
- return this.active.get(assistantMessageID) ?? this.parked.get(assistantMessageID)?.state
203
- }
204
- }