-
Notifications
You must be signed in to change notification settings - Fork 499
Warn callers when logs MCP tool returns stale data with no date range specified
#53719
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
2908858
f5a9881
6aa603b
2e5c78f
e9d6d4f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| # ADR-53719: Warn Callers When `logs` MCP Tool Returns Stale Data Without a Date Range | ||
|
|
||
| **Date**: 2026-08-18 | ||
| **Status**: Draft | ||
| **Deciders**: pelikhan, copilot-swe-agent | ||
|
|
||
| --- | ||
|
|
||
| ### Context | ||
|
|
||
| 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). | ||
|
|
||
| ### Decision | ||
|
|
||
| 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. | ||
|
|
||
| ### Alternatives Considered | ||
|
|
||
| #### Alternative 1: Require explicit date bounds (hard rejection) | ||
|
|
||
| Reject any `logs` call that omits both `start_date` and `end_date`, returning an error that forces the caller to specify a range. | ||
|
|
||
| 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. | ||
|
|
||
| #### Alternative 2: Transparent auto-retry with a default date window | ||
|
|
||
| Detect staleness and automatically re-issue the query with a sensible default `start_date` (e.g., `-1d`) without telling the caller. | ||
|
|
||
| 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. | ||
|
|
||
| ### Consequences | ||
|
|
||
| #### Positive | ||
| - Callers receive an actionable warning in the MCP response's top-level `message` field immediately, without reading the cached file. | ||
| - No breaking change: callers that already supply explicit date bounds see no difference in behavior. | ||
| - The 48-hour threshold is documented as a named constant (`staleLogsWarningThreshold`), making it easy to tune. | ||
|
|
||
| #### Negative | ||
| - 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. | ||
| - 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. | ||
|
|
||
| #### Neutral | ||
| - 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. | ||
| - The `renderLogsOutputOptions` struct gains two new fields (`startDate`, `endDate`) that are threaded from `DownloadWorkflowLogs`, a minor expansion of the internal API surface. | ||
|
|
||
| --- | ||
|
|
||
| *ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.* |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -368,3 +368,44 @@ func TestCollectProcessedWorkflowRunsAccumulatesBatches(t *testing.T) { | |
| assert.Equal(t, int64(1), runs[0].Run.DatabaseID) | ||
| assert.Equal(t, int64(3), runs[2].Run.DatabaseID) | ||
| } | ||
|
|
||
| // TestStaleLogsWarning verifies that a warning is only emitted when no explicit | ||
| // start_date/end_date was requested and the newest run in the result set is older | ||
| // than the staleness threshold. This guards against the "logs" tool silently | ||
| // serving stale data without any indication when called with only a count. | ||
| func TestStaleLogsWarning(t *testing.T) { | ||
| t.Run("no warning when start date explicitly provided", func(t *testing.T) { | ||
| runs := []ProcessedRun{{Run: WorkflowRun{CreatedAt: time.Now().Add(-30 * 24 * time.Hour)}}} | ||
| assert.Empty(t, staleLogsWarning(runs, "-1d", "")) | ||
| }) | ||
|
|
||
| t.Run("no warning when end date explicitly provided", func(t *testing.T) { | ||
| runs := []ProcessedRun{{Run: WorkflowRun{CreatedAt: time.Now().Add(-30 * 24 * time.Hour)}}} | ||
| assert.Empty(t, staleLogsWarning(runs, "", "2024-01-01")) | ||
| }) | ||
|
|
||
| t.Run("no warning when no runs", func(t *testing.T) { | ||
| assert.Empty(t, staleLogsWarning(nil, "", "")) | ||
| }) | ||
|
|
||
| t.Run("no warning when newest run is recent", func(t *testing.T) { | ||
| runs := []ProcessedRun{ | ||
| {Run: WorkflowRun{CreatedAt: time.Now().Add(-1 * time.Hour)}}, | ||
| {Run: WorkflowRun{CreatedAt: time.Now().Add(-40 * 24 * time.Hour)}}, | ||
| } | ||
| assert.Empty(t, staleLogsWarning(runs, "", "")) | ||
| }) | ||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/diagnosing-bugs] The 💡 Suggested renamenewest := time.Now().Add(-11 * 24 * time.Hour)
runs := []ProcessedRun{
{Run: WorkflowRun{CreatedAt: newest}},
{Run: WorkflowRun{CreatedAt: newest.Add(-time.Hour)}},
}While here, assert the warning contains "11 day" to lock in the human-readable format. @copilot please address this. |
||
| t.Run("warns when no dates given and newest run is old", func(t *testing.T) { | ||
| newest := time.Now().Add(-11 * 24 * time.Hour) | ||
| runs := []ProcessedRun{ | ||
| {Run: WorkflowRun{CreatedAt: newest}}, | ||
| {Run: WorkflowRun{CreatedAt: newest.Add(-time.Hour)}}, | ||
| } | ||
| warning := staleLogsWarning(runs, "", "") | ||
| require.NotEmpty(t, warning) | ||
| assert.Contains(t, warning, "No start_date/end_date was specified") | ||
| assert.Contains(t, warning, "start_date") | ||
| assert.Contains(t, warning, "11 day") | ||
| }) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,6 +7,7 @@ import ( | |
| "fmt" | ||
| "os" | ||
| "path/filepath" | ||
| "strings" | ||
|
|
||
| "github.com/github/gh-aw/pkg/constants" | ||
| "github.com/github/gh-aw/pkg/logger" | ||
|
|
@@ -51,6 +52,23 @@ func extractLogsContinuation(outputStr string) *ContinuationData { | |
| return parsed.Continuation | ||
| } | ||
|
|
||
| // extractLogsStaleWarning returns the top-level "stale_warning" field embedded | ||
| // in the logs JSON output, or "" when absent or unparseable. This is a | ||
| // dedicated field (distinct from the generic "message" field, which is also | ||
| // used for non-warning hints such as the usage-only artifact hint) so that | ||
| // only genuine stale-data warnings are surfaced as "WARNING" in the MCP | ||
| // response. | ||
| func extractLogsStaleWarning(outputStr string) string { | ||
| var parsed struct { | ||
| StaleWarning string `json:"stale_warning"` | ||
| } | ||
| if err := json.Unmarshal([]byte(outputStr), &parsed); err != nil { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/diagnosing-bugs] 💡 Suggested fixLog the parse error at debug level so it's visible under func extractLogsMessage(outputStr string) string {
var parsed struct {
Message string `json:"message"`
}
if err := json.Unmarshal([]byte(outputStr), &parsed); err != nil {
mcpLogsGuardrailLog.Printf("extractLogsMessage: failed to parse output JSON: %v", err)
return ""
}
return parsed.Message
}@copilot please address this. |
||
| mcpLogsGuardrailLog.Printf("extractLogsStaleWarning: failed to parse output JSON: %v", err) | ||
| return "" | ||
| } | ||
| return parsed.StaleWarning | ||
| } | ||
|
|
||
| // buildLogsFileResponse writes the logs JSON output to a content-addressed cache | ||
| // file and returns a JSON response containing the file path. | ||
| // The file is named by the SHA256 hash of its content so that identical results | ||
|
|
@@ -134,14 +152,25 @@ func buildLogsFileResponse(outputStr string) string { | |
| } | ||
|
|
||
| response := MCPLogsGuardrailResponse{ | ||
| Message: fmt.Sprintf("Logs data has been written to '%s'. Use the file_path to read the full data.", filePath), | ||
| FilePath: filePath, | ||
| } | ||
| if continuation := extractLogsContinuation(outputStr); continuation != nil { | ||
|
|
||
| var msgs []string | ||
| continuation := extractLogsContinuation(outputStr) | ||
| if continuation != nil { | ||
| response.Partial = true | ||
| response.Continuation = continuation | ||
| 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) | ||
| 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)) | ||
| } else { | ||
| msgs = append(msgs, fmt.Sprintf("Logs data has been written to '%s'. Use the file_path to read the full data.", filePath)) | ||
| } | ||
| // Surface the stale-data warning (when no date range was requested and the | ||
| // newest run returned is unexpectedly old) directly in the tool response so | ||
| // callers see it without having to open the file. | ||
| if warning := extractLogsStaleWarning(outputStr); warning != "" { | ||
| msgs = append(msgs, "WARNING: "+warning) | ||
| } | ||
| response.Message = strings.Join(msgs, " ") | ||
|
|
||
| responseJSON, err := json.MarshalIndent(response, "", " ") | ||
| if err != nil { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -207,3 +207,45 @@ func TestBuildLogsFileResponse_CompleteResultsNotPartial(t *testing.T) { | |
| // Cleanup | ||
| _ = os.Remove(response.FilePath) | ||
| } | ||
|
|
||
| func TestBuildLogsFileResponse_SurfacesStaleDataWarning(t *testing.T) { | ||
| 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."}` | ||
|
|
||
| result := buildLogsFileResponse(output) | ||
|
|
||
| var response MCPLogsGuardrailResponse | ||
| if err := json.Unmarshal([]byte(result), &response); err != nil { | ||
| t.Fatalf("Response should be valid JSON: %v", err) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/tdd] 💡 Suggested fixSwitch to require.NoError(t, json.Unmarshal([]byte(result), &response))
assert.Contains(t, response.Message, "WARNING:")
assert.Contains(t, response.Message, "No start_date/end_date was specified")@copilot please address this. |
||
| } | ||
|
|
||
| if !strings.Contains(response.Message, "WARNING:") { | ||
| t.Errorf("Message should surface the embedded warning, got %q", response.Message) | ||
| } | ||
| if !strings.Contains(response.Message, "No start_date/end_date was specified") { | ||
| t.Errorf("Message should include the stale-data warning text, got %q", response.Message) | ||
| } | ||
|
|
||
| // Cleanup | ||
| _ = os.Remove(response.FilePath) | ||
| } | ||
|
|
||
| func TestBuildLogsFileResponse_DoesNotWarnOnOrdinaryMessage(t *testing.T) { | ||
| // A non-stale "message" field (e.g. the usage-only artifact hint) must not | ||
| // be relabeled as a WARNING; only the dedicated "stale_warning" field should | ||
| // trigger the WARNING prefix. | ||
| output := `{"summary":{"total_runs":1},"runs":[],"message":"Only the usage artifact was downloaded."}` | ||
|
|
||
| result := buildLogsFileResponse(output) | ||
|
|
||
| var response MCPLogsGuardrailResponse | ||
| if err := json.Unmarshal([]byte(result), &response); err != nil { | ||
| t.Fatalf("Response should be valid JSON: %v", err) | ||
| } | ||
|
|
||
| if strings.Contains(response.Message, "WARNING:") { | ||
| t.Errorf("Message should not contain WARNING for a non-stale message, got %q", response.Message) | ||
| } | ||
|
|
||
| // Cleanup | ||
| _ = os.Remove(response.FilePath) | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[/diagnosing-bugs]
age.Round(time.Hour)formats as"264h0m0s"— callers see a raw Go duration string rather than "11 days old" as shown in the PR description. A human-readable form is far more actionable for agents and users.💡 Suggested fix
Add a small helper and use it in the Sprintf:
Also add a test that asserts the stale-data warning for 11-day-old data contains
"11 day"to prevent format regressions.@copilot please address this.