Skip to content

Commit cb90a29

Browse files
committed
feat(telemetry): elapsed-ratio ETA with per-step history blending and Feishu heartbeat
1 parent ae82c6e commit cb90a29

2 files changed

Lines changed: 222 additions & 65 deletions

File tree

AWS/src/core/telemetry/tracker.py

Lines changed: 176 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,62 +1,176 @@
1-
from __future__ import annotations
2-
import time
3-
import logging
4-
from typing import Optional, List
5-
6-
logger = logging.getLogger(__name__)
7-
8-
class TimeEstimator:
9-
"""
10-
Provides static ETA estimations based on heuristics.
11-
In a production system, this could query a database of historical execution times.
12-
"""
13-
14-
WORKFLOW_AVERAGES = {
15-
"amazon_bsr": 30.0, # Baseline 30 seconds
16-
# Add others as needed
17-
}
18-
19-
AGENT_BASELINE_PER_ITERATION = 6.0 # Approx 12s per ReAct loop (LLM + Tool)
20-
21-
@classmethod
22-
def estimate_workflow(cls, workflow_name: str, params: dict = None) -> str:
23-
base_time = cls.WORKFLOW_AVERAGES.get(workflow_name, 30.0)
24-
# E.g., if we know pagination params, we could multiply base_time
25-
return f"~{int(base_time)}秒"
26-
27-
@classmethod
28-
def estimate_agent(cls, max_iterations: int = 5) -> str:
29-
# Agent is highly variable, give a conservative range
30-
min_time = int(cls.AGENT_BASELINE_PER_ITERATION * 1)
31-
max_time = int(cls.AGENT_BASELINE_PER_ITERATION * (max_iterations * 0.6)) # Assume it rarely hits max
32-
return f"{min_time}~{max_time}秒"
33-
34-
35-
class TelemetryTracker:
36-
"""
37-
Tracks dynamic progress and calculates moving averages for remaining ETA.
38-
"""
39-
def __init__(self, total_steps: int):
40-
self.total_steps = total_steps
41-
self.current_step = 0
42-
self.start_time = time.monotonic()
43-
self.step_times: List[float] = []
44-
self.last_step_time = self.start_time
45-
46-
def record_step(self):
47-
"""Record the completion of a step and track its duration."""
48-
now = time.monotonic()
49-
duration = now - self.last_step_time
50-
self.step_times.append(duration)
51-
self.last_step_time = now
52-
self.current_step += 1
53-
54-
def get_dynamic_eta(self) -> Optional[int]:
55-
"""Returns the estimated remaining time in seconds."""
56-
if self.current_step == 0 or self.current_step >= self.total_steps:
57-
return None
58-
59-
# Simple Average for remaining steps
60-
avg_time_per_step = sum(self.step_times) / len(self.step_times)
61-
remaining_steps = self.total_steps - self.current_step
62-
return int(avg_time_per_step * remaining_steps)
1+
from __future__ import annotations
2+
import json
3+
import logging
4+
import os
5+
import time
6+
from typing import Dict, List, Optional
7+
8+
logger = logging.getLogger(__name__)
9+
10+
# Per-step history stored here; keyed by step_name, value = list of durations (s)
11+
_HISTORY_PATH = os.path.join(os.path.dirname(__file__), "step_history.json")
12+
_HISTORY_MAX_SAMPLES = 20 # rolling window per step
13+
14+
15+
def _load_history() -> Dict[str, List[float]]:
16+
try:
17+
with open(_HISTORY_PATH, encoding="utf-8") as f:
18+
return json.load(f)
19+
except Exception:
20+
return {}
21+
22+
23+
def _save_history(history: Dict[str, List[float]]) -> None:
24+
try:
25+
with open(_HISTORY_PATH, "w", encoding="utf-8") as f:
26+
json.dump(history, f)
27+
except Exception as e:
28+
logger.debug(f"Could not save step history: {e}")
29+
30+
31+
class TimeEstimator:
32+
"""
33+
Static ETA estimations based on heuristics (kept for backward compat).
34+
"""
35+
36+
WORKFLOW_AVERAGES = {
37+
"amazon_bsr": 30.0,
38+
}
39+
AGENT_BASELINE_PER_ITERATION = 6.0
40+
41+
@classmethod
42+
def estimate_workflow(cls, workflow_name: str, params: dict = None) -> str:
43+
base_time = cls.WORKFLOW_AVERAGES.get(workflow_name, 30.0)
44+
return f"~{int(base_time)}秒"
45+
46+
@classmethod
47+
def estimate_agent(cls, max_iterations: int = 5) -> str:
48+
min_time = int(cls.AGENT_BASELINE_PER_ITERATION * 1)
49+
max_time = int(cls.AGENT_BASELINE_PER_ITERATION * (max_iterations * 0.6))
50+
return f"{min_time}~{max_time}秒"
51+
52+
53+
class TelemetryTracker:
54+
"""
55+
Tracks dynamic progress and calculates ETA using two complementary methods:
56+
57+
1. Elapsed-ratio (always available, no history needed):
58+
eta = elapsed_total × remaining_steps / completed_steps
59+
Same algorithm used by curl, wget, and GitHub Actions progress bars.
60+
Robust because it doesn't assume equal step durations — it uses actual
61+
wall time.
62+
63+
2. Per-step historical baseline (learned from past runs):
64+
eta = Σ historical_avg(step_i) for remaining steps
65+
Stored in step_history.json. When N ≥ 3 samples exist for a step,
66+
the estimate is blended: 40 % elapsed-ratio + 60 % historical.
67+
68+
Confidence tiers shown in the UI:
69+
🔴 < 2 completed steps — wild guess, elapsed-ratio only
70+
🟡 2–4 completed steps — improving estimate
71+
🟢 history N ≥ 3 per step — data-backed estimate
72+
"""
73+
74+
def __init__(self, total_steps: int, workflow_name: str = ""):
75+
self.total_steps = total_steps
76+
self.workflow_name = workflow_name
77+
self.current_step = 0
78+
self.start_time = time.monotonic()
79+
self.step_times: List[float] = []
80+
self.step_names: List[str] = []
81+
self.last_step_time = self.start_time
82+
self._history: Dict[str, List[float]] = _load_history()
83+
84+
def record_step(self, step_name: str = "") -> None:
85+
now = time.monotonic()
86+
duration = now - self.last_step_time
87+
self.step_times.append(duration)
88+
self.step_names.append(step_name)
89+
self.last_step_time = now
90+
self.current_step += 1
91+
92+
# Persist to rolling history
93+
if step_name:
94+
key = f"{self.workflow_name}:{step_name}" if self.workflow_name else step_name
95+
samples = self._history.setdefault(key, [])
96+
samples.append(duration)
97+
if len(samples) > _HISTORY_MAX_SAMPLES:
98+
samples.pop(0)
99+
_save_history(self._history)
100+
101+
# ── ETA methods ──────────────────────────────────────────────────────
102+
103+
def _elapsed_ratio_eta(self) -> Optional[float]:
104+
"""eta = elapsed × remaining / completed (download-bar method)."""
105+
if self.current_step == 0:
106+
return None
107+
elapsed = time.monotonic() - self.start_time
108+
remaining = self.total_steps - self.current_step
109+
return elapsed * remaining / self.current_step
110+
111+
def _historical_eta(self, remaining_step_names: List[str]) -> Optional[float]:
112+
"""Sum of historical averages for the remaining steps."""
113+
if not remaining_step_names:
114+
return None
115+
total = 0.0
116+
covered = 0
117+
for name in remaining_step_names:
118+
key = f"{self.workflow_name}:{name}" if self.workflow_name else name
119+
samples = self._history.get(key, [])
120+
if len(samples) >= 3:
121+
total += sum(samples) / len(samples)
122+
covered += 1
123+
if covered == 0:
124+
return None
125+
# Scale up to cover steps with no history (proportional)
126+
total *= len(remaining_step_names) / covered
127+
return total
128+
129+
def _min_history_samples(self, remaining_step_names: List[str]) -> int:
130+
"""Minimum number of samples across remaining steps (confidence proxy)."""
131+
mins = []
132+
for name in remaining_step_names:
133+
key = f"{self.workflow_name}:{name}" if self.workflow_name else name
134+
mins.append(len(self._history.get(key, [])))
135+
return min(mins) if mins else 0
136+
137+
def get_dynamic_eta(
138+
self,
139+
remaining_step_names: Optional[List[str]] = None,
140+
) -> Optional[str]:
141+
"""
142+
Return a human-readable ETA string, or None when estimate is unavailable.
143+
144+
Parameters
145+
----------
146+
remaining_step_names:
147+
Optional list of future step names (enables historical blending).
148+
"""
149+
if self.current_step == 0 or self.current_step >= self.total_steps:
150+
return None
151+
152+
er_eta = self._elapsed_ratio_eta()
153+
hist_eta = self._historical_eta(remaining_step_names or [])
154+
min_samples = self._min_history_samples(remaining_step_names or [])
155+
156+
# Blend: weight historical more heavily when data is solid
157+
if hist_eta is not None and min_samples >= 3:
158+
eta = 0.4 * (er_eta or hist_eta) + 0.6 * hist_eta
159+
confidence = "🟢"
160+
elif er_eta is not None and self.current_step >= 2:
161+
eta = er_eta
162+
confidence = "🟡"
163+
elif er_eta is not None:
164+
eta = er_eta
165+
confidence = "🔴"
166+
else:
167+
return None
168+
169+
secs = max(1, int(eta))
170+
if secs >= 60:
171+
mins, s = divmod(secs, 60)
172+
eta_str = f"{mins}{s:02d}秒" if s else f"{mins}分钟"
173+
else:
174+
eta_str = f"{secs}秒"
175+
176+
return f"{confidence} 预计剩余 {eta_str}"

AWS/src/jobs/callbacks/feishu.py

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@
1212
import asyncio
1313
import functools
1414
import logging
15+
import time
1516
from datetime import datetime
16-
from typing import Set
17+
from typing import Optional, Set
1718

1819

1920
def _to_thread(func, *args, **kwargs):
@@ -32,6 +33,9 @@ def _to_thread(func, *args, **kwargs):
3233
_CIRCUIT_OPEN_THRESHOLD = 3 # consecutive failures before opening circuit
3334
_CIRCUIT_COOLDOWN_STEPS = 5 # steps to skip before retrying
3435

36+
_HEARTBEAT_INTERVAL = 10 # seconds between heartbeat card updates
37+
_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
38+
3539

3640
class FeishuCallback(JobCallback):
3741
"""
@@ -69,6 +73,11 @@ def __init__(
6973
self._consecutive_failures = 0
7074
self._cooldown_remaining = 0
7175

76+
# Heartbeat state
77+
self._heartbeat_task: Optional[asyncio.Task] = None
78+
self._heartbeat_base_text: str = ""
79+
self._heartbeat_start: float = 0.0
80+
7281
@property
7382
def feishu(self):
7483
if self._feishu is None:
@@ -100,6 +109,32 @@ def _is_circuit_open(self) -> bool:
100109
return True
101110
return False
102111

112+
# ── Heartbeat ────────────────────────────────────────────────────────
113+
114+
def _cancel_heartbeat(self) -> None:
115+
if self._heartbeat_task and not self._heartbeat_task.done():
116+
self._heartbeat_task.cancel()
117+
self._heartbeat_task = None
118+
119+
async def _heartbeat_loop(self) -> None:
120+
"""Periodically update the progress card while a step is running."""
121+
frame_idx = 0
122+
try:
123+
while True:
124+
await asyncio.sleep(_HEARTBEAT_INTERVAL)
125+
if self._is_circuit_open():
126+
continue
127+
elapsed = int(time.monotonic() - self._heartbeat_start)
128+
spinner = _SPINNER_FRAMES[frame_idx % len(_SPINNER_FRAMES)]
129+
frame_idx += 1
130+
heartbeat_text = (
131+
f"{self._heartbeat_base_text}\n"
132+
f"{spinner} 正在执行中… 已等待 {elapsed}s"
133+
)
134+
asyncio.create_task(self._send_progress(heartbeat_text))
135+
except asyncio.CancelledError:
136+
pass
137+
103138
# ── Progress (fire-and-forget, non-blocking) ─────────────────────────
104139

105140
async def on_progress(
@@ -108,7 +143,7 @@ async def on_progress(
108143
if self._tracker.total_steps != total_steps:
109144
self._tracker.total_steps = total_steps
110145

111-
self._tracker.record_step()
146+
self._tracker.record_step(step_name)
112147

113148
if self._is_circuit_open():
114149
return
@@ -122,7 +157,13 @@ async def on_progress(
122157

123158
eta = self._tracker.get_dynamic_eta()
124159
if eta is not None:
125-
text += f"\n⏳ 动态预计剩余: {eta}秒"
160+
text += f"\n{eta}"
161+
162+
# Cancel previous heartbeat and start a fresh one for this step
163+
self._cancel_heartbeat()
164+
self._heartbeat_base_text = text
165+
self._heartbeat_start = time.monotonic()
166+
self._heartbeat_task = asyncio.create_task(self._heartbeat_loop())
126167

127168
# Fire-and-forget: do not block the workflow on Feishu I/O
128169
asyncio.create_task(self._send_progress(text))
@@ -320,6 +361,7 @@ async def _send_interaction_card(self, signal: dict) -> None:
320361
logger.info(f"Feishu send_raw_card response: {send_res}")
321362

322363
async def on_complete(self, result) -> None:
364+
self._cancel_heartbeat()
323365
try:
324366
import os
325367
items = result.final_items if hasattr(result, "final_items") else []
@@ -476,6 +518,7 @@ async def on_complete(self, result) -> None:
476518
pass
477519

478520
async def on_error(self, error: Exception, job_id: str = None) -> None:
521+
self._cancel_heartbeat()
479522
lines = [f"❌ Workflow failed: {error}"]
480523
if job_id:
481524
lines.append(f"Job ID: `{job_id}`")

0 commit comments

Comments
 (0)