Skip to content

Commit 76155fd

Browse files
Copilotpelikhangithub-actions[bot]claude
authored
Warn callers when logs MCP tool returns stale data with no date range specified (#53719)
* Initial plan * Warn when default (no date range) logs query returns stale data Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> * Address code review feedback on stale-data warning formatting Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> * Add draft ADR-53719: warn on stale logs without date range Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Address review feedback: gate staleness check, dedicated warning field, humanized age Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent e8c8e22 commit 76155fd

9 files changed

Lines changed: 293 additions & 4 deletions
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# ADR-53719: Warn Callers When `logs` MCP Tool Returns Stale Data Without a Date Range
2+
3+
**Date**: 2026-08-18
4+
**Status**: Draft
5+
**Deciders**: pelikhan, copilot-swe-agent
6+
7+
---
8+
9+
### Context
10+
11+
The `logs` MCP tool paginates workflow runs backwards by creation date until it has collected the requested count. In high-activity repositories, this walk can silently land on a window that is days or weeks old when recent non-agentic runs dominate the page. Callers that omit `start_date`/`end_date` receive stale data with no signal that more recent runs exist, causing deep-report and audit workflows to draw incorrect conclusions about current fleet health (see issue #53683).
12+
13+
### Decision
14+
15+
We will add a post-query staleness check (`staleLogsWarning`) that fires only when no date range was requested and the newest run in the result set is older than 48 hours. The warning is folded into the output's `message` field by `renderLogsOutput`, then extracted and surfaced verbatim in the MCP tool's top-level response text by `buildLogsFileResponse` so callers see it immediately without opening the cached file.
16+
17+
### Alternatives Considered
18+
19+
#### Alternative 1: Require explicit date bounds (hard rejection)
20+
21+
Reject any `logs` call that omits both `start_date` and `end_date`, returning an error that forces the caller to specify a range.
22+
23+
This eliminates the ambiguity entirely but is a breaking change: all existing callers that rely on the count-only invocation pattern would need to be updated simultaneously, and some legitimate use-cases (e.g., "give me the last N runs regardless of when they ran") become impossible to express.
24+
25+
#### Alternative 2: Transparent auto-retry with a default date window
26+
27+
Detect staleness and automatically re-issue the query with a sensible default `start_date` (e.g., `-1d`) without telling the caller.
28+
29+
This silently fixes the common case but hides the ambiguity rather than exposing it. Callers lose visibility into the scope of their query; if the auto-selected window is wrong for a given repo's cadence, results are still wrong—and now there is no warning to prompt investigation.
30+
31+
### Consequences
32+
33+
#### Positive
34+
- Callers receive an actionable warning in the MCP response's top-level `message` field immediately, without reading the cached file.
35+
- No breaking change: callers that already supply explicit date bounds see no difference in behavior.
36+
- The 48-hour threshold is documented as a named constant (`staleLogsWarningThreshold`), making it easy to tune.
37+
38+
#### Negative
39+
- The staleness heuristic (48 hours) is repo-cadence-agnostic; low-activity repositories may never trigger the warning even when results are genuinely stale, while repos with infrequent runs could trigger false positives.
40+
- The warning travels through two encoding layers (render → JSON `message` field → guardrail JSON extraction), creating coupling between `renderLogsOutput` and `buildLogsFileResponse` that can silently break if the output schema changes.
41+
42+
#### Neutral
43+
- Unit tests cover all four guard conditions: explicit start date, explicit end date, empty result set, and recent-data threshold, providing a regression baseline for future threshold changes.
44+
- The `renderLogsOutputOptions` struct gains two new fields (`startDate`, `endDate`) that are threaded from `DownloadWorkflowLogs`, a minor expansion of the internal API surface.
45+
46+
---
47+
48+
*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.*

pkg/cli/logs_orchestrator.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,68 @@ func applyMetricsTurnsToRun(run *WorkflowRun, metrics LogMetrics) {
4141
}
4242
}
4343

44+
// staleLogsWarningThreshold is how old the most recent run in a result set may be,
45+
// when no explicit start_date/end_date was requested, before we warn the caller
46+
// that the data may not reflect the truly latest workflow runs.
47+
//
48+
// When no date range is supplied, pagination walks backwards through time (paging
49+
// on run creation date) until it has collected the requested count of runs. In a
50+
// repository with heavy non-agentic or still-in-progress run volume, this walk can
51+
// silently settle on an old window without any indication to the caller that more
52+
// recent runs exist. Passing an explicit start_date/end_date bypasses this ambiguity
53+
// entirely because it bounds the query server-side, so no warning is needed there.
54+
const staleLogsWarningThreshold = 48 * time.Hour
55+
56+
// humanizeDuration formats a duration as a coarse, human-readable age such as
57+
// "11 days" or "5 hours", rather than a raw Go duration string like "264h0m0s".
58+
func humanizeDuration(d time.Duration) string {
59+
if days := int(d.Hours()) / 24; days >= 1 {
60+
if days == 1 {
61+
return "1 day"
62+
}
63+
return fmt.Sprintf("%d days", days)
64+
}
65+
if hours := int(d.Hours()); hours >= 1 {
66+
if hours == 1 {
67+
return "1 hour"
68+
}
69+
return fmt.Sprintf("%d hours", hours)
70+
}
71+
return "less than 1 hour"
72+
}
73+
74+
// staleLogsWarning returns a warning message when a date-unbounded logs query
75+
// (no start_date/end_date requested) returns a result set whose most recent run
76+
// is unexpectedly old. Returns "" when no warning is warranted, i.e. when an
77+
// explicit date range was requested, there are no runs, or the newest run is
78+
// recent enough.
79+
func staleLogsWarning(processedRuns []ProcessedRun, startDate, endDate string) string {
80+
if startDate != "" || endDate != "" {
81+
// Caller supplied an explicit bound; the result is exactly what was asked for.
82+
return ""
83+
}
84+
if len(processedRuns) == 0 {
85+
return ""
86+
}
87+
var newest time.Time
88+
for _, pr := range processedRuns {
89+
if pr.Run.CreatedAt.After(newest) {
90+
newest = pr.Run.CreatedAt
91+
}
92+
}
93+
if newest.IsZero() {
94+
return ""
95+
}
96+
age := time.Since(newest)
97+
if age < staleLogsWarningThreshold {
98+
return ""
99+
}
100+
return fmt.Sprintf(
101+
"No start_date/end_date was specified, and the most recent run in this result is %s old (created %s). "+
102+
"Retry with an explicit start_date (e.g. \"-1d\") to confirm you are seeing the latest workflow runs.",
103+
humanizeDuration(age), newest.Format(time.RFC3339))
104+
}
105+
44106
// noRunsMessage returns a human-readable explanation for why zero workflow runs
45107
// were returned. It inspects the startDate filter and the timeoutReached flag
46108
// so callers receive actionable guidance instead of a silent empty result.
@@ -183,5 +245,8 @@ func DownloadWorkflowLogs(ctx context.Context, opts LogsDownloadOptions) error {
183245
continuation: continuation,
184246
verbose: opts.Verbose,
185247
artifactFilter: runtime.artifactFilter,
248+
startDate: opts.StartDate,
249+
endDate: opts.EndDate,
250+
checkStaleness: true,
186251
})
187252
}

pkg/cli/logs_orchestrator_render.go

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"io"
1111
"os"
1212
"path/filepath"
13+
"strings"
1314

1415
"github.com/github/gh-aw/pkg/constants"
1516
)
@@ -29,10 +30,26 @@ func renderLogsOutput(processedRuns []ProcessedRun, opts renderLogsOutputOptions
2930
logsOrchestratorLog.Printf("Building logs data from %d processed runs (continuation=%t)", len(processedRuns), opts.continuation != nil)
3031
logsData := buildLogsData(processedRuns, opts.outputDir, opts.continuation)
3132

33+
// When no explicit start_date/end_date was requested and the newest run in the
34+
// result is unexpectedly old, warn the caller so stale data is never served
35+
// silently (see issue: logs MCP tool returns stale data without date params).
36+
// This only applies to discovery-mode rendering (pagination walking backwards
37+
// through time); the stdin path processes explicit run IDs with no pagination,
38+
// so the check is skipped there.
39+
if opts.checkStaleness {
40+
if warning := staleLogsWarning(processedRuns, opts.startDate, opts.endDate); warning != "" {
41+
logsData.StaleWarning = warning
42+
}
43+
}
44+
3245
// When only the usage artifact was downloaded, add a hint so consumers know how
3346
// to fetch additional artifact sets (agent logs, firewall data, etc.).
47+
var hints []string
3448
if isUsageOnlyArtifactFilter(opts.artifactFilter) {
35-
logsData.Message = usageOnlyArtifactHintMessage()
49+
hints = append(hints, usageOnlyArtifactHintMessage())
50+
}
51+
if len(hints) > 0 {
52+
logsData.Message = strings.Join(hints, " ")
3653
}
3754

3855
// Write summary file if requested (default behavior unless disabled with empty string)

pkg/cli/logs_orchestrator_types.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,4 +89,11 @@ type renderLogsOutputOptions struct {
8989
continuation *ContinuationData
9090
verbose bool
9191
artifactFilter []string
92+
startDate string
93+
endDate string
94+
// checkStaleness enables the stale-data warning check. It is only meaningful
95+
// for discovery-mode rendering (pagination walking backwards through time
96+
// looking for runs); the stdin path processes explicit run IDs with no
97+
// pagination, so it leaves this false.
98+
checkStaleness bool
9299
}

pkg/cli/logs_orchestrator_unit_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,3 +368,44 @@ func TestCollectProcessedWorkflowRunsAccumulatesBatches(t *testing.T) {
368368
assert.Equal(t, int64(1), runs[0].Run.DatabaseID)
369369
assert.Equal(t, int64(3), runs[2].Run.DatabaseID)
370370
}
371+
372+
// TestStaleLogsWarning verifies that a warning is only emitted when no explicit
373+
// start_date/end_date was requested and the newest run in the result set is older
374+
// than the staleness threshold. This guards against the "logs" tool silently
375+
// serving stale data without any indication when called with only a count.
376+
func TestStaleLogsWarning(t *testing.T) {
377+
t.Run("no warning when start date explicitly provided", func(t *testing.T) {
378+
runs := []ProcessedRun{{Run: WorkflowRun{CreatedAt: time.Now().Add(-30 * 24 * time.Hour)}}}
379+
assert.Empty(t, staleLogsWarning(runs, "-1d", ""))
380+
})
381+
382+
t.Run("no warning when end date explicitly provided", func(t *testing.T) {
383+
runs := []ProcessedRun{{Run: WorkflowRun{CreatedAt: time.Now().Add(-30 * 24 * time.Hour)}}}
384+
assert.Empty(t, staleLogsWarning(runs, "", "2024-01-01"))
385+
})
386+
387+
t.Run("no warning when no runs", func(t *testing.T) {
388+
assert.Empty(t, staleLogsWarning(nil, "", ""))
389+
})
390+
391+
t.Run("no warning when newest run is recent", func(t *testing.T) {
392+
runs := []ProcessedRun{
393+
{Run: WorkflowRun{CreatedAt: time.Now().Add(-1 * time.Hour)}},
394+
{Run: WorkflowRun{CreatedAt: time.Now().Add(-40 * 24 * time.Hour)}},
395+
}
396+
assert.Empty(t, staleLogsWarning(runs, "", ""))
397+
})
398+
399+
t.Run("warns when no dates given and newest run is old", func(t *testing.T) {
400+
newest := time.Now().Add(-11 * 24 * time.Hour)
401+
runs := []ProcessedRun{
402+
{Run: WorkflowRun{CreatedAt: newest}},
403+
{Run: WorkflowRun{CreatedAt: newest.Add(-time.Hour)}},
404+
}
405+
warning := staleLogsWarning(runs, "", "")
406+
require.NotEmpty(t, warning)
407+
assert.Contains(t, warning, "No start_date/end_date was specified")
408+
assert.Contains(t, warning, "start_date")
409+
assert.Contains(t, warning, "11 day")
410+
})
411+
}

pkg/cli/logs_output_hint_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,42 @@ func TestRenderLogsOutputWritesArtifactHintForNonCompactFormats(t *testing.T) {
5555
})
5656
}
5757
}
58+
59+
func TestRenderLogsOutputStaleWarningGatedByCheckStaleness(t *testing.T) {
60+
// A result set whose newest run is well past the staleness threshold with no
61+
// start_date/end_date requested.
62+
processedRuns := []ProcessedRun{{
63+
Run: WorkflowRun{
64+
DatabaseID: 1,
65+
Status: "completed",
66+
WorkflowName: "logs",
67+
CreatedAt: time.Now().Add(-11 * 24 * time.Hour),
68+
},
69+
}}
70+
71+
t.Run("discovery mode surfaces the stale warning", func(t *testing.T) {
72+
stdout, _ := captureOutput(t, func() error {
73+
return renderLogsOutput(processedRuns, renderLogsOutputOptions{
74+
outputDir: t.TempDir(),
75+
format: "console",
76+
jsonOutput: true,
77+
checkStaleness: true,
78+
})
79+
})
80+
81+
assert.Contains(t, stdout, "No start_date/end_date was specified")
82+
})
83+
84+
t.Run("stdin/explicit-run mode does not surface the stale warning", func(t *testing.T) {
85+
stdout, _ := captureOutput(t, func() error {
86+
return renderLogsOutput(processedRuns, renderLogsOutputOptions{
87+
outputDir: t.TempDir(),
88+
format: "console",
89+
jsonOutput: true,
90+
// checkStaleness left false, as it is for DownloadWorkflowLogsFromStdin.
91+
})
92+
})
93+
94+
assert.NotContains(t, stdout, "No start_date/end_date was specified")
95+
})
96+
}

pkg/cli/logs_report.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ type LogsData struct {
3939
Continuation *ContinuationData `json:"continuation,omitempty" console:"-"`
4040
LogsLocation string `json:"logs_location" console:"-"`
4141
Message string `json:"message,omitempty" console:"-"`
42+
StaleWarning string `json:"stale_warning,omitempty" console:"-"`
4243
}
4344

4445
// ContinuationData provides parameters to continue querying when timeout is reached

pkg/cli/mcp_logs_guardrail.go

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"fmt"
88
"os"
99
"path/filepath"
10+
"strings"
1011

1112
"github.com/github/gh-aw/pkg/constants"
1213
"github.com/github/gh-aw/pkg/logger"
@@ -51,6 +52,23 @@ func extractLogsContinuation(outputStr string) *ContinuationData {
5152
return parsed.Continuation
5253
}
5354

55+
// extractLogsStaleWarning returns the top-level "stale_warning" field embedded
56+
// in the logs JSON output, or "" when absent or unparseable. This is a
57+
// dedicated field (distinct from the generic "message" field, which is also
58+
// used for non-warning hints such as the usage-only artifact hint) so that
59+
// only genuine stale-data warnings are surfaced as "WARNING" in the MCP
60+
// response.
61+
func extractLogsStaleWarning(outputStr string) string {
62+
var parsed struct {
63+
StaleWarning string `json:"stale_warning"`
64+
}
65+
if err := json.Unmarshal([]byte(outputStr), &parsed); err != nil {
66+
mcpLogsGuardrailLog.Printf("extractLogsStaleWarning: failed to parse output JSON: %v", err)
67+
return ""
68+
}
69+
return parsed.StaleWarning
70+
}
71+
5472
// buildLogsFileResponse writes the logs JSON output to a content-addressed cache
5573
// file and returns a JSON response containing the file path.
5674
// The file is named by the SHA256 hash of its content so that identical results
@@ -134,14 +152,25 @@ func buildLogsFileResponse(outputStr string) string {
134152
}
135153

136154
response := MCPLogsGuardrailResponse{
137-
Message: fmt.Sprintf("Logs data has been written to '%s'. Use the file_path to read the full data.", filePath),
138155
FilePath: filePath,
139156
}
140-
if continuation := extractLogsContinuation(outputStr); continuation != nil {
157+
158+
var msgs []string
159+
continuation := extractLogsContinuation(outputStr)
160+
if continuation != nil {
141161
response.Partial = true
142162
response.Continuation = continuation
143-
response.Message = fmt.Sprintf("PARTIAL RESULTS: the download stopped before all matching runs were collected. %s Partial logs data has been written to '%s'. Use the file_path to read the collected data and the continuation parameters to fetch the remaining logs.", continuation.Message, filePath)
163+
msgs = append(msgs, fmt.Sprintf("PARTIAL RESULTS: the download stopped before all matching runs were collected. %s Partial logs data has been written to '%s'. Use the file_path to read the collected data and the continuation parameters to fetch the remaining logs.", continuation.Message, filePath))
164+
} else {
165+
msgs = append(msgs, fmt.Sprintf("Logs data has been written to '%s'. Use the file_path to read the full data.", filePath))
166+
}
167+
// Surface the stale-data warning (when no date range was requested and the
168+
// newest run returned is unexpectedly old) directly in the tool response so
169+
// callers see it without having to open the file.
170+
if warning := extractLogsStaleWarning(outputStr); warning != "" {
171+
msgs = append(msgs, "WARNING: "+warning)
144172
}
173+
response.Message = strings.Join(msgs, " ")
145174

146175
responseJSON, err := json.MarshalIndent(response, "", " ")
147176
if err != nil {

pkg/cli/mcp_logs_guardrail_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,3 +207,45 @@ func TestBuildLogsFileResponse_CompleteResultsNotPartial(t *testing.T) {
207207
// Cleanup
208208
_ = os.Remove(response.FilePath)
209209
}
210+
211+
func TestBuildLogsFileResponse_SurfacesStaleDataWarning(t *testing.T) {
212+
output := `{"summary":{"total_runs":1},"runs":[],"stale_warning":"No start_date/end_date was specified, and the most recent run in this result is 11 days old."}`
213+
214+
result := buildLogsFileResponse(output)
215+
216+
var response MCPLogsGuardrailResponse
217+
if err := json.Unmarshal([]byte(result), &response); err != nil {
218+
t.Fatalf("Response should be valid JSON: %v", err)
219+
}
220+
221+
if !strings.Contains(response.Message, "WARNING:") {
222+
t.Errorf("Message should surface the embedded warning, got %q", response.Message)
223+
}
224+
if !strings.Contains(response.Message, "No start_date/end_date was specified") {
225+
t.Errorf("Message should include the stale-data warning text, got %q", response.Message)
226+
}
227+
228+
// Cleanup
229+
_ = os.Remove(response.FilePath)
230+
}
231+
232+
func TestBuildLogsFileResponse_DoesNotWarnOnOrdinaryMessage(t *testing.T) {
233+
// A non-stale "message" field (e.g. the usage-only artifact hint) must not
234+
// be relabeled as a WARNING; only the dedicated "stale_warning" field should
235+
// trigger the WARNING prefix.
236+
output := `{"summary":{"total_runs":1},"runs":[],"message":"Only the usage artifact was downloaded."}`
237+
238+
result := buildLogsFileResponse(output)
239+
240+
var response MCPLogsGuardrailResponse
241+
if err := json.Unmarshal([]byte(result), &response); err != nil {
242+
t.Fatalf("Response should be valid JSON: %v", err)
243+
}
244+
245+
if strings.Contains(response.Message, "WARNING:") {
246+
t.Errorf("Message should not contain WARNING for a non-stale message, got %q", response.Message)
247+
}
248+
249+
// Cleanup
250+
_ = os.Remove(response.FilePath)
251+
}

0 commit comments

Comments
 (0)