Watch
1
0
Fork
You've already forked RedFlag
0

feat: surface retry context in unified history

A retried command carries the same action/result as its original, so the
history read as a fresh attempt. The lineage already lived in
agent_commands.retried_from_id — it just was not projected.

GetAllUnifiedHistory now selects is_retry + retried_from_id (both UNION
halves; logs are always false/null); UnifiedHistoryItem carries them; the
handler prefixes the narrative with "Retry — ". ChatTimeline composes its
own command sentences (narrative is only a log fallback), so it gets the
same prefix guarded by entry.is_retry, matching the is_retry/
retried_from_id convention LiveOperations already consumes.

Also drop a leftover heartbeat console.log debug block in Agents.tsx.
This commit is contained in:
Fimeg 2026-05-29 22:04:49 -04:00
commit 10a51fa56f
4 changed files with 27 additions and 12 deletions

View file

@ -1553,8 +1553,14 @@ func (h *UpdateHandler) GetAllLogs(c *gin.Context) {
}
// Populate narrative so the UI never has to compose its own verbiage.
// A retry carries the same action/result as its original, so the renderer
// stays focused on the outcome and the retry context is prefixed here.
for i := range items {
items[i].Narrative = services.RenderUpdateLog(items[i].Action, items[i].Result, items[i].Stderr)
narrative := services.RenderUpdateLog(items[i].Action, items[i].Result, items[i].Stderr)
if items[i].IsRetry {
narrative = "Retry — " + narrative
}
items[i].Narrative = narrative
}
c.JSON(http.StatusOK, gin.H{

View file

@ -352,7 +352,7 @@ func (q *UpdateQueries) ListAggregatedPackages(search, packageType, sortBy, sort
COUNT(*) FILTER (WHERE status = 'failed') AS failed_count,
COUNT(*) FILTER (WHERE status = 'pending_dependencies') AS pending_dependencies_count,
MAX(last_discovered_at) AS last_discovered_at,
MIN(id) AS representative_id
MIN(id::text)::uuid AS representative_id
FROM current_package_state
%s
GROUP BY package_type, package_name
@ -1012,6 +1012,11 @@ type UnifiedHistoryItem struct {
DurationSeconds int `json:"duration_seconds" db:"duration_seconds"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
Hostname string `json:"hostname" db:"hostname"`
// IsRetry marks a command that re-attempts an earlier one (retried_from_id
// set). Logs are never retries. RetriedFromID links to the original command
// so the UI can thread the chain. Both come straight from the row.
IsRetry bool `json:"is_retry" db:"is_retry"`
RetriedFromID *uuid.UUID `json:"retried_from_id,omitempty" db:"retried_from_id"`
// Narrative is a server-rendered, operator-facing summary. Populated by
// the handler before responding (services.RenderUpdateLog). Not stored.
Narrative string `json:"narrative,omitempty" db:"-"`
@ -1067,7 +1072,9 @@ func (q *UpdateQueries) GetAllUnifiedHistory(filters *models.LogFilters) ([]Unif
COALESCE((ac.result->>'exit_code')::int, 0) as exit_code,
COALESCE((ac.result->>'duration_seconds')::int, 0) as duration_seconds,
ac.created_at,
COALESCE(a.hostname, '') as hostname
COALESCE(a.hostname, '') as hostname,
(ac.retried_from_id IS NOT NULL) as is_retry,
ac.retried_from_id
FROM agent_commands ac
LEFT JOIN agents a ON ac.agent_id = a.id
WHERE %s
@ -1089,7 +1096,9 @@ func (q *UpdateQueries) GetAllUnifiedHistory(filters *models.LogFilters) ([]Unif
ul.exit_code,
ul.duration_seconds,
ul.executed_at as created_at,
COALESCE(a.hostname, '') as hostname
COALESCE(a.hostname, '') as hostname,
false as is_retry,
NULL::uuid as retried_from_id
FROM update_logs ul
LEFT JOIN agents a ON ul.agent_id = a.id
WHERE %s

View file

@ -43,6 +43,8 @@ interface HistoryEntry {
params?: Record<string, any>;
hostname?: string;
narrative?: string; // server-supplied summary; falls back to action enum when absent
is_retry?: boolean; // command re-attempts an earlier one (retried_from_id set)
retried_from_id?: string; // links to the original command in the retry chain
}
interface ChatTimelineProps {
@ -510,6 +512,12 @@ const ChatTimeline: React.FC<ChatTimelineProps> = ({ agentId, className, isScope
}
}
// A retried command carries the same action/result as its original; mark it
// so the history reads as a re-attempt rather than a fresh action.
if (entry.is_retry && sentence) {
sentence = `Retry — ${sentence}`;
}
// Add agent location for global view
if (!isScopedView && entry.hostname) {
sentence += ` on ${entry.hostname}`;

View file

@ -610,14 +610,6 @@ const Agents: React.FC = () => {
// Get source from heartbeat status (stored in agent metadata)
const heartbeatSource = heartbeatStatus?.source;
// Debug: Log the source field
console.log('[Heartbeat Debug]', {
isRapidPolling,
source: heartbeatSource,
sourceType: typeof heartbeatSource,
heartbeatStatus
});
// Check if heartbeat is system-initiated (blue) or manual (pink)
const isSystemHeartbeat = heartbeatSource === 'system';
const isManualHeartbeat = heartbeatSource === 'manual';