feat: add optimistic locking for bttask and error classification
Version column in tasks table with WHERE id=? AND version=? guard. Conflict detection in TaskBoardTab. error-classifier.ts: 6 error types with actionable messages and retry logic. UsageMeter.svelte.
This commit is contained in:
parent
0fe43de357
commit
3cb65fd5e5
10 changed files with 763 additions and 32 deletions
|
|
@ -16,6 +16,8 @@
|
|||
import type { SessionAnchor } from '../../types/anchors';
|
||||
|
||||
import AgentTree from './AgentTree.svelte';
|
||||
import UsageMeter from './UsageMeter.svelte';
|
||||
import { notify } from '../../stores/notifications.svelte';
|
||||
import { getHighlighter, highlightCode, escapeHtml } from '../../utils/highlight';
|
||||
import type {
|
||||
TextContent,
|
||||
|
|
@ -373,6 +375,35 @@
|
|||
if (totalTokens === 0) return 0;
|
||||
return Math.min(100, Math.round((totalTokens / DEFAULT_CONTEXT_LIMIT) * 100));
|
||||
});
|
||||
|
||||
// Context meter color class based on thresholds
|
||||
let contextColorClass = $derived.by(() => {
|
||||
if (contextPercent >= 90) return 'context-critical';
|
||||
if (contextPercent >= 75) return 'context-high';
|
||||
if (contextPercent >= 50) return 'context-medium';
|
||||
return '';
|
||||
});
|
||||
|
||||
// Session burn rate ($/hr)
|
||||
let burnRatePerHr = $derived.by(() => {
|
||||
if (!session || session.durationMs <= 0 || session.costUsd <= 0) return 0;
|
||||
return (session.costUsd / session.durationMs) * 3_600_000;
|
||||
});
|
||||
|
||||
// 90% context warning (fire once per session)
|
||||
let contextWarningFired = $state(false);
|
||||
$effect(() => {
|
||||
if (contextPercent >= 90 && !contextWarningFired && session?.status === 'running') {
|
||||
contextWarningFired = true;
|
||||
notify('warning', `Context usage at ${contextPercent}% — approaching model limit`);
|
||||
}
|
||||
});
|
||||
// Reset warning tracker when session changes
|
||||
$effect(() => {
|
||||
if (session?.id) {
|
||||
contextWarningFired = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="agent-pane" data-testid="agent-pane" data-agent-status={session?.status ?? 'idle'}>
|
||||
|
|
@ -538,12 +569,17 @@
|
|||
<div class="running-indicator">
|
||||
<span class="pulse"></span>
|
||||
<span>Running...</span>
|
||||
{#if contextPercent > 0}
|
||||
<span class="context-meter" title="Context window usage">
|
||||
<span class="context-fill" class:context-streaming={isRunning} style="width: {contextPercent}%"></span>
|
||||
{#if capabilities.supportsCost && (session.inputTokens > 0 || session.outputTokens > 0)}
|
||||
<UsageMeter inputTokens={session.inputTokens} outputTokens={session.outputTokens} contextLimit={DEFAULT_CONTEXT_LIMIT} />
|
||||
{:else if contextPercent > 0}
|
||||
<span class="context-meter {contextColorClass}" title="Context window usage">
|
||||
<span class="context-fill context-streaming" style="width: {contextPercent}%"></span>
|
||||
<span class="context-label">{contextPercent}%</span>
|
||||
</span>
|
||||
{/if}
|
||||
{#if burnRatePerHr > 0}
|
||||
<span class="burn-rate" title="Current session burn rate">${burnRatePerHr.toFixed(2)}/hr</span>
|
||||
{/if}
|
||||
{#if !autoScroll}
|
||||
<button class="scroll-btn" onclick={() => { autoScroll = true; scrollContainer?.querySelector('#message-end')?.scrollIntoView({ behavior: 'instant' as ScrollBehavior }); }}>↓ Bottom</button>
|
||||
{/if}
|
||||
|
|
@ -555,14 +591,17 @@
|
|||
{#if totalCost && totalCost.costUsd > session.costUsd}
|
||||
<span class="total-cost">(total: ${totalCost.costUsd.toFixed(4)})</span>
|
||||
{/if}
|
||||
<span class="cost-detail">{session.inputTokens + session.outputTokens} tok</span>
|
||||
<span class="cost-detail">{(session.durationMs / 1000).toFixed(1)}s</span>
|
||||
{#if contextPercent > 0}
|
||||
<span class="context-meter" title="Context window usage">
|
||||
<span class="context-fill" style="width: {contextPercent}%"></span>
|
||||
<span class="context-label">{contextPercent}%</span>
|
||||
</span>
|
||||
{#if capabilities.supportsCost}
|
||||
<span class="cost-detail token-in" title="Input tokens">{session.inputTokens.toLocaleString()} in</span>
|
||||
<span class="cost-detail token-out" title="Output tokens">{session.outputTokens.toLocaleString()} out</span>
|
||||
{:else}
|
||||
<span class="cost-detail">{session.inputTokens + session.outputTokens} tok</span>
|
||||
{/if}
|
||||
<span class="cost-detail">{(session.durationMs / 1000).toFixed(1)}s</span>
|
||||
{#if burnRatePerHr > 0}
|
||||
<span class="burn-rate" title="Session average burn rate">${burnRatePerHr.toFixed(2)}/hr</span>
|
||||
{/if}
|
||||
<UsageMeter inputTokens={session.inputTokens} outputTokens={session.outputTokens} contextLimit={DEFAULT_CONTEXT_LIMIT} />
|
||||
{#if !autoScroll}
|
||||
<button class="scroll-btn" onclick={() => { autoScroll = true; scrollContainer?.querySelector('#message-end')?.scrollIntoView({ behavior: 'instant' as ScrollBehavior }); }}>↓ Bottom</button>
|
||||
{/if}
|
||||
|
|
@ -1333,6 +1372,21 @@
|
|||
.total-cost { color: var(--ctp-overlay1); font-size: 0.6875rem; }
|
||||
.error-bar { color: var(--ctp-red); }
|
||||
|
||||
.burn-rate {
|
||||
font-size: 0.625rem;
|
||||
font-family: var(--term-font-family, monospace);
|
||||
color: var(--ctp-peach);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.token-in { color: var(--ctp-blue); }
|
||||
.token-out { color: var(--ctp-green); }
|
||||
|
||||
/* Context meter threshold colors */
|
||||
.context-medium .context-fill { background: var(--ctp-yellow); }
|
||||
.context-high .context-fill { background: var(--ctp-peach); }
|
||||
.context-critical .context-fill { background: var(--ctp-red); }
|
||||
|
||||
/* === Session controls === */
|
||||
.session-controls {
|
||||
display: flex;
|
||||
|
|
|
|||
146
v2/src/lib/components/Agent/UsageMeter.svelte
Normal file
146
v2/src/lib/components/Agent/UsageMeter.svelte
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
contextLimit?: number;
|
||||
}
|
||||
|
||||
let { inputTokens, outputTokens, contextLimit = 200_000 }: Props = $props();
|
||||
|
||||
let totalTokens = $derived(inputTokens + outputTokens);
|
||||
let pct = $derived(contextLimit > 0 ? Math.min((totalTokens / contextLimit) * 100, 100) : 0);
|
||||
|
||||
let thresholdClass = $derived.by(() => {
|
||||
if (pct >= 90) return 'critical';
|
||||
if (pct >= 75) return 'high';
|
||||
if (pct >= 50) return 'medium';
|
||||
return 'low';
|
||||
});
|
||||
|
||||
function formatTokens(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
let showTooltip = $state(false);
|
||||
</script>
|
||||
|
||||
{#if totalTokens > 0}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="usage-meter"
|
||||
class:critical={thresholdClass === 'critical'}
|
||||
class:high={thresholdClass === 'high'}
|
||||
class:medium={thresholdClass === 'medium'}
|
||||
class:low={thresholdClass === 'low'}
|
||||
onmouseenter={() => showTooltip = true}
|
||||
onmouseleave={() => showTooltip = false}
|
||||
>
|
||||
<div class="meter-track">
|
||||
<div class="meter-fill" style="width: {pct}%"></div>
|
||||
</div>
|
||||
<span class="meter-label">{formatTokens(totalTokens)}</span>
|
||||
|
||||
{#if showTooltip}
|
||||
<div class="meter-tooltip">
|
||||
<div class="tooltip-row">
|
||||
<span class="tooltip-key">Input</span>
|
||||
<span class="tooltip-val">{formatTokens(inputTokens)}</span>
|
||||
</div>
|
||||
<div class="tooltip-row">
|
||||
<span class="tooltip-key">Output</span>
|
||||
<span class="tooltip-val">{formatTokens(outputTokens)}</span>
|
||||
</div>
|
||||
<div class="tooltip-row">
|
||||
<span class="tooltip-key">Total</span>
|
||||
<span class="tooltip-val">{formatTokens(totalTokens)}</span>
|
||||
</div>
|
||||
<div class="tooltip-divider"></div>
|
||||
<div class="tooltip-row">
|
||||
<span class="tooltip-key">Limit</span>
|
||||
<span class="tooltip-val">{formatTokens(contextLimit)}</span>
|
||||
</div>
|
||||
<div class="tooltip-row">
|
||||
<span class="tooltip-key">Used</span>
|
||||
<span class="tooltip-val">{pct.toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.usage-meter {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.meter-track {
|
||||
width: 3rem;
|
||||
height: 0.375rem;
|
||||
background: var(--ctp-surface0);
|
||||
border-radius: 0.1875rem;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.meter-fill {
|
||||
height: 100%;
|
||||
border-radius: 0.1875rem;
|
||||
transition: width 0.3s ease, background 0.3s ease;
|
||||
}
|
||||
|
||||
.low .meter-fill { background: var(--ctp-green); }
|
||||
.medium .meter-fill { background: var(--ctp-yellow); }
|
||||
.high .meter-fill { background: var(--ctp-peach); }
|
||||
.critical .meter-fill { background: var(--ctp-red); }
|
||||
|
||||
.meter-label {
|
||||
font-size: 0.625rem;
|
||||
font-family: var(--term-font-family, monospace);
|
||||
color: var(--ctp-overlay1);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.meter-tooltip {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 0.375rem);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--ctp-surface0);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.375rem 0.5rem;
|
||||
min-width: 7.5rem;
|
||||
z-index: 100;
|
||||
box-shadow: 0 0.125rem 0.5rem color-mix(in srgb, var(--ctp-crust) 40%, transparent);
|
||||
}
|
||||
|
||||
.tooltip-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.0625rem 0;
|
||||
}
|
||||
|
||||
.tooltip-key {
|
||||
font-size: 0.625rem;
|
||||
color: var(--ctp-overlay0);
|
||||
}
|
||||
|
||||
.tooltip-val {
|
||||
font-size: 0.625rem;
|
||||
font-family: var(--term-font-family, monospace);
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.tooltip-divider {
|
||||
height: 1px;
|
||||
background: var(--ctp-surface1);
|
||||
margin: 0.1875rem 0;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -262,6 +262,31 @@
|
|||
return `${m}m ${s % 60}s`;
|
||||
}
|
||||
|
||||
// --- Cost analytics ---
|
||||
let avgCostPerTurn = $derived(
|
||||
session && session.numTurns > 0
|
||||
? totalCost.costUsd / session.numTurns
|
||||
: 0
|
||||
);
|
||||
|
||||
let tokenEfficiency = $derived(
|
||||
totalCost.inputTokens > 0
|
||||
? totalCost.outputTokens / totalCost.inputTokens
|
||||
: 0
|
||||
);
|
||||
|
||||
let burnRatePerHr = $derived.by(() => {
|
||||
if (!session || session.durationMs <= 0 || totalCost.costUsd <= 0) return 0;
|
||||
return (totalCost.costUsd / session.durationMs) * 3_600_000;
|
||||
});
|
||||
|
||||
// Cost projection: estimate total cost if context fills to 100%
|
||||
let costProjection = $derived.by(() => {
|
||||
const usedPct = totalCost.inputTokens / CONTEXT_WINDOW;
|
||||
if (usedPct <= 0) return 0;
|
||||
return totalCost.costUsd / usedPct;
|
||||
});
|
||||
|
||||
function opColor(op: string): string {
|
||||
switch (op) {
|
||||
case 'read': return 'var(--ctp-blue)';
|
||||
|
|
@ -689,6 +714,39 @@
|
|||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Cost Analytics -->
|
||||
{#if totalCost.costUsd > 0}
|
||||
<div class="cost-analytics-section">
|
||||
<div class="section-header">
|
||||
<span class="section-title">Cost Analytics</span>
|
||||
</div>
|
||||
<div class="cost-grid">
|
||||
<div class="cost-cell">
|
||||
<span class="cost-cell-value">{formatCost(totalCost.costUsd)}</span>
|
||||
<span class="cost-cell-label">Total Cost</span>
|
||||
</div>
|
||||
<div class="cost-cell">
|
||||
<span class="cost-cell-value">{formatCost(avgCostPerTurn)}</span>
|
||||
<span class="cost-cell-label">Avg / Turn</span>
|
||||
</div>
|
||||
<div class="cost-cell">
|
||||
<span class="cost-cell-value">{tokenEfficiency.toFixed(2)}</span>
|
||||
<span class="cost-cell-label">Out/In Ratio</span>
|
||||
</div>
|
||||
<div class="cost-cell">
|
||||
<span class="cost-cell-value">${burnRatePerHr.toFixed(2)}/hr</span>
|
||||
<span class="cost-cell-label">Burn Rate</span>
|
||||
</div>
|
||||
{#if costProjection > 0}
|
||||
<div class="cost-cell projection">
|
||||
<span class="cost-cell-value">{formatCost(costProjection)}</span>
|
||||
<span class="cost-cell-label">Est. Full Context</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Context Meter -->
|
||||
<div class="meter-section">
|
||||
<div class="meter-header">
|
||||
|
|
@ -1139,6 +1197,52 @@
|
|||
.compaction-pill .stat-value { color: var(--ctp-yellow); }
|
||||
.compaction-pill .stat-label { color: var(--ctp-yellow); opacity: 0.7; }
|
||||
|
||||
/* Cost Analytics */
|
||||
.cost-analytics-section {
|
||||
padding: 0.375rem 0.625rem;
|
||||
border-bottom: 1px solid var(--ctp-surface0);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cost-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(5rem, 1fr));
|
||||
gap: 0.375rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.cost-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 0.375rem 0.25rem;
|
||||
background: var(--ctp-surface0);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.cost-cell-value {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
font-family: var(--term-font-family, monospace);
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.cost-cell-label {
|
||||
font-size: 0.55rem;
|
||||
color: var(--ctp-overlay0);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
.cost-cell.projection {
|
||||
background: color-mix(in srgb, var(--ctp-peach) 10%, var(--ctp-surface0));
|
||||
}
|
||||
|
||||
.cost-cell.projection .cost-cell-value {
|
||||
color: var(--ctp-peach);
|
||||
}
|
||||
|
||||
/* Context meter */
|
||||
.meter-section {
|
||||
padding: 0.5rem 0.625rem;
|
||||
|
|
|
|||
|
|
@ -84,10 +84,18 @@
|
|||
|
||||
async function handleStatusChange(taskId: string, newStatus: string) {
|
||||
try {
|
||||
await updateTaskStatus(taskId, newStatus);
|
||||
const task = tasks.find(t => t.id === taskId);
|
||||
const version = task?.version ?? 1;
|
||||
await updateTaskStatus(taskId, newStatus, version);
|
||||
await loadTasks();
|
||||
} catch (e) {
|
||||
console.warn('Failed to update task status:', e);
|
||||
} catch (e: any) {
|
||||
const msg = e?.message ?? String(e);
|
||||
if (msg.includes('version conflict')) {
|
||||
console.warn('Version conflict on task update, reloading:', msg);
|
||||
await loadTasks();
|
||||
} else {
|
||||
console.warn('Failed to update task status:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue