Merge pull request #1779 from pikasTech/fix/1770-turn-timing-authority

fix: keep workbench turn timing monotonic
This commit is contained in:
Lyon
2026-06-21 04:31:46 +08:00
committed by GitHub
2 changed files with 142 additions and 13 deletions
@@ -233,7 +233,8 @@ function traceForDisplay(message: ChatMessage): ChatMessage["runnerTrace"] {
function messageDurationMeta(message: ChatMessage): { text: string; label: string } | null {
if (message.role !== "agent") return null;
const durationMs = isRunningMessage(message) ? durationSince(message.timing?.startedAt) : finiteDurationMs(message.timing?.durationMs);
const timing = messageTimingForDisplay(message);
const durationMs = isRunningMessage(message) ? durationSince(timing?.startedAt) : finiteDurationMs(timing?.durationMs);
if (durationMs === null) return null;
const value = formatDuration(durationMs);
return { text: `耗时 ${value}`, label: `总耗时:${value}` };
@@ -241,12 +242,17 @@ function messageDurationMeta(message: ChatMessage): { text: string; label: strin
function messageActivityMeta(message: ChatMessage): { text: string; label: string } | null {
if (!isRunningMessage(message)) return null;
const ageMs = durationSince(message.timing?.lastEventAt);
const timing = messageTimingForDisplay(message);
const ageMs = durationSince(timing?.lastEventAt);
if (ageMs === null) return null;
const value = formatDuration(ageMs) + "前";
return { text: `最近 ${value}`, label: `最近事件:${value}` };
}
function messageTimingForDisplay(message: ChatMessage): ChatMessage["timing"] {
return message.runnerTrace?.timing ?? message.timing ?? null;
}
function durationSince(timestamp: unknown): number | null {
const startedAtMs = timestampMs(timestamp);
return startedAtMs === null ? null : Math.max(0, nowMs.value - startedAtMs);
@@ -118,7 +118,128 @@ export function mergeRunnerTrace(previous: ChatMessage["runnerTrace"], next: Non
: keepPreviousEvents && previousAuthoritative && next.eventsCompacted === true ? previousEvents
: keepPreviousEvents ? mergeTraceEvents(previousEvents, nextEvents) : nextEvents.length > previousEvents.length ? mergeTraceEvents(previousEvents, nextEvents) : nextEvents;
const eventCount = keepPreviousEvents ? previous.eventCount ?? events.length : next.eventCount ?? previous.eventCount ?? events.length;
return { ...previous, ...next, events, eventCount, updatedAt: next.updatedAt ?? previous.updatedAt ?? new Date().toISOString() };
const timing = mergeTraceTimingProjection(previous, next, { preserveVisibleTerminalDuration: traceTimingWasRunning(previous) && traceTimingIsTerminal(next) });
return {
...previous,
...next,
events,
eventCount,
timing,
startedAt: timing?.startedAt ?? next.startedAt ?? previous.startedAt ?? null,
lastEventAt: timing?.lastEventAt ?? next.lastEventAt ?? previous.lastEventAt ?? null,
finishedAt: timing?.finishedAt ?? next.finishedAt ?? previous.finishedAt ?? null,
durationMs: timing?.durationMs ?? next.durationMs ?? previous.durationMs ?? null,
updatedAt: next.updatedAt ?? previous.updatedAt ?? new Date().toISOString()
};
}
function mergeTraceTimingProjection(previous: unknown, next: unknown, options: { preserveVisibleTerminalDuration?: boolean } = {}): TraceSnapshot["timing"] | null {
const previousTiming = traceTimingCandidate(previous);
const nextTiming = traceTimingCandidate(next);
if (!previousTiming && !nextTiming) return null;
if (!previousTiming) return nextTiming;
if (!nextTiming) return previousTiming;
const startedAt = earliestTraceTimestamp(previousTiming.startedAt, nextTiming.startedAt);
const lastEventAt = latestTraceTimestamp(previousTiming.lastEventAt, nextTiming.lastEventAt);
const finishedAt = latestTraceTimestamp(previousTiming.finishedAt, nextTiming.finishedAt);
const elapsedDurationMs = traceElapsedMs(startedAt, finishedAt ?? lastEventAt);
const visibleTerminalDurationMs = options.preserveVisibleTerminalDuration === true ? traceElapsedMs(startedAt, new Date().toISOString()) : null;
const durationMs = maxTraceDuration(previousTiming.durationMs, nextTiming.durationMs, elapsedDurationMs, visibleTerminalDurationMs);
return {
...previousTiming,
...nextTiming,
startedAt,
lastEventAt,
finishedAt,
durationMs,
observedAt: latestTraceTimestamp(previousTiming.observedAt, nextTiming.observedAt),
lastEventAgeMs: null,
valuesRedacted: previousTiming.valuesRedacted !== false && nextTiming.valuesRedacted !== false
};
}
function traceTimingCandidate(value: unknown): TraceSnapshot["timing"] | null {
const record = traceRecord(value);
const timing = traceRecord(record?.timing);
if (!record && !timing) return null;
const startedAt = firstTraceTimestamp(timing?.startedAt, record?.startedAt);
const lastEventAt = firstTraceTimestamp(timing?.lastEventAt, record?.lastEventAt);
const finishedAt = firstTraceTimestamp(timing?.finishedAt, record?.finishedAt);
const observedAt = firstTraceTimestamp(timing?.observedAt, record?.observedAt);
const durationMs = maxTraceDuration(timing?.durationMs, record?.durationMs);
const lastEventAgeMs = finiteTraceDuration(timing?.lastEventAgeMs ?? record?.lastEventAgeMs);
if (!startedAt && !lastEventAt && !finishedAt && !observedAt && durationMs === null && lastEventAgeMs === null) return null;
return { ...(timing ?? {}), startedAt, lastEventAt, finishedAt, observedAt, durationMs, lastEventAgeMs, valuesRedacted: timing?.valuesRedacted !== false };
}
function traceRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" ? value as Record<string, unknown> : null;
}
function firstTraceTimestamp(...values: unknown[]): string | null {
for (const value of values) {
if (typeof value !== "string") continue;
const text = value.trim();
if (!text) continue;
if (Number.isFinite(Date.parse(text))) return text;
}
return null;
}
function earliestTraceTimestamp(...values: unknown[]): string | null {
return selectTraceTimestamp("earliest", values);
}
function latestTraceTimestamp(...values: unknown[]): string | null {
return selectTraceTimestamp("latest", values);
}
function selectTraceTimestamp(mode: "earliest" | "latest", values: unknown[]): string | null {
let selected: { value: string; ms: number } | null = null;
for (const value of values) {
const timestamp = firstTraceTimestamp(value);
if (!timestamp) continue;
const ms = Date.parse(timestamp);
if (!selected || (mode === "earliest" ? ms < selected.ms : ms > selected.ms)) selected = { value: timestamp, ms };
}
return selected?.value ?? null;
}
function traceElapsedMs(startedAt: unknown, endedAt: unknown): number | null {
const start = startedAt == null ? NaN : Date.parse(String(startedAt));
const end = endedAt == null ? NaN : Date.parse(String(endedAt));
if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return null;
return Math.trunc(end - start);
}
function maxTraceDuration(...values: unknown[]): number | null {
let max: number | null = null;
for (const value of values) {
const duration = finiteTraceDuration(value);
if (duration === null) continue;
if (max === null || duration > max) max = duration;
}
return max;
}
function finiteTraceDuration(value: unknown): number | null {
const number = Number(value);
return Number.isFinite(number) && number >= 0 ? Math.trunc(number) : null;
}
function traceTimingWasRunning(value: unknown): boolean {
const record = traceRecord(value);
if (!record) return false;
const status = typeof record.status === "string" ? record.status.trim().toLowerCase() : "";
if (["completed", "failed", "cancelled", "canceled", "error"].includes(status)) return false;
const timing = traceTimingCandidate(value);
return !timing?.finishedAt;
}
function traceTimingIsTerminal(value: unknown): boolean {
const record = traceRecord(value);
const status = typeof record?.status === "string" ? record.status.trim().toLowerCase() : "";
return ["completed", "failed", "cancelled", "canceled", "error"].includes(status) || traceTimingCandidate(value)?.finishedAt != null;
}
function mergeTraceEvents(previousEvents: TraceEvent[], nextEvents: TraceEvent[]): TraceEvent[] {
@@ -298,22 +419,24 @@ function mergeTraceSnapshots(previous: TraceSnapshot | null, next: TraceSnapshot
const previousEvents = Array.isArray(previous.events) ? previous.events : [];
const nextEvents = Array.isArray(next.events) ? next.events : [];
const events = mergeTraceEvents(previousEvents, nextEvents);
const timing = mergeTraceTimingProjection(previous, next, { preserveVisibleTerminalDuration: traceTimingWasRunning(previous) && traceTimingIsTerminal(next) });
return {
...previous,
...next,
events,
eventCount: next.eventCount ?? previous.eventCount ?? events.length,
timing: next.timing ?? previous.timing ?? null,
startedAt: next.startedAt ?? next.timing?.startedAt ?? previous.startedAt ?? previous.timing?.startedAt ?? null,
lastEventAt: next.lastEventAt ?? next.timing?.lastEventAt ?? previous.lastEventAt ?? previous.timing?.lastEventAt ?? null,
finishedAt: next.finishedAt ?? next.timing?.finishedAt ?? previous.finishedAt ?? previous.timing?.finishedAt ?? null,
durationMs: next.durationMs ?? next.timing?.durationMs ?? previous.durationMs ?? previous.timing?.durationMs ?? null,
timing,
startedAt: timing?.startedAt ?? next.startedAt ?? next.timing?.startedAt ?? previous.startedAt ?? previous.timing?.startedAt ?? null,
lastEventAt: timing?.lastEventAt ?? next.lastEventAt ?? next.timing?.lastEventAt ?? previous.lastEventAt ?? previous.timing?.lastEventAt ?? null,
finishedAt: timing?.finishedAt ?? next.finishedAt ?? next.timing?.finishedAt ?? previous.finishedAt ?? previous.timing?.finishedAt ?? null,
durationMs: timing?.durationMs ?? next.durationMs ?? next.timing?.durationMs ?? previous.durationMs ?? previous.timing?.durationMs ?? null,
lastEventLabel: next.lastEventLabel ?? previous.lastEventLabel ?? undefined,
updatedAt: next.updatedAt ?? previous.updatedAt ?? new Date().toISOString()
};
}
function traceSnapshotWithTurnStatus(trace: TraceSnapshot, turn: TraceSnapshot): TraceSnapshot {
const timing = mergeTraceTimingProjection(trace, turn, { preserveVisibleTerminalDuration: traceTimingWasRunning(trace) && traceTimingIsTerminal(turn) });
return {
...trace,
traceId: trace.traceId ?? turn.traceId,
@@ -325,11 +448,11 @@ function traceSnapshotWithTurnStatus(trace: TraceSnapshot, turn: TraceSnapshot):
terminalEvidence: trace.terminalEvidence ?? turn.terminalEvidence,
traceSummary: trace.traceSummary ?? turn.traceSummary,
error: trace.error ?? turn.error,
timing: turn.timing ?? trace.timing ?? null,
startedAt: turn.startedAt ?? turn.timing?.startedAt ?? trace.startedAt ?? trace.timing?.startedAt ?? null,
lastEventAt: turn.lastEventAt ?? turn.timing?.lastEventAt ?? trace.lastEventAt ?? trace.timing?.lastEventAt ?? null,
finishedAt: turn.finishedAt ?? turn.timing?.finishedAt ?? trace.finishedAt ?? trace.timing?.finishedAt ?? null,
durationMs: turn.durationMs ?? turn.timing?.durationMs ?? trace.durationMs ?? trace.timing?.durationMs ?? null,
timing,
startedAt: timing?.startedAt ?? turn.startedAt ?? turn.timing?.startedAt ?? trace.startedAt ?? trace.timing?.startedAt ?? null,
lastEventAt: timing?.lastEventAt ?? turn.lastEventAt ?? turn.timing?.lastEventAt ?? trace.lastEventAt ?? trace.timing?.lastEventAt ?? null,
finishedAt: timing?.finishedAt ?? turn.finishedAt ?? turn.timing?.finishedAt ?? trace.finishedAt ?? trace.timing?.finishedAt ?? null,
durationMs: timing?.durationMs ?? turn.durationMs ?? turn.timing?.durationMs ?? trace.durationMs ?? trace.timing?.durationMs ?? null,
projection: trace.projection ?? turn.projection ?? null,
projectionStatus: trace.projectionStatus ?? turn.projectionStatus ?? null,
projectionHealth: trace.projectionHealth ?? turn.projectionHealth ?? null,