Skip to content

Commit 0b2d79f

Browse files
benibenjCopilot
andauthored
agentHost: improve Agent Merge CI diagnostics (#335501)
* agentHost: improve Agent Merge CI diagnostics Replace prefix-only CI dumps with bounded summaries and authorized, cached tail/range/search reads. Preserve true EOF completeness, redaction, cancellation, and workflow-attempt identity while keeping responses and retained evidence bounded. Serialize concurrent reads and paginate summaries within cache capacity so follow-up evidence remains actionable without changing repair scheduling. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: index Agent Merge CI evidence for bounded seeking Cache sparse line-start offsets so search continuations, context excerpts, ranges, and tails do not repeatedly scan logs from the beginning. Cover 500,000-line pagination, newline-dense 16 MiB logs, checkpoint boundaries, and empty evidence. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 315ed2a commit 0b2d79f

12 files changed

Lines changed: 1813 additions & 73 deletions
Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import { RunOnceScheduler } from '../../../base/common/async.js';
7+
import { VSBuffer } from '../../../base/common/buffer.js';
8+
import { Event } from '../../../base/common/event.js';
9+
import { Disposable, DisposableMap } from '../../../base/common/lifecycle.js';
10+
import { LRUCache } from '../../../base/common/map.js';
11+
import { escapeRegExpCharacters } from '../../../base/common/strings.js';
12+
import { generateUuid } from '../../../base/common/uuid.js';
13+
import { GitHubWorkflowJob, GitHubWorkflowLog } from '../../github/common/githubPullRequestMutationService.js';
14+
import { AgentMergeCIRequest } from './shared/agentMergeServerTools.js';
15+
16+
export const agentMergeCIResponseBytes = 48_000;
17+
const evidenceLifetime = 5 * 60_000;
18+
const maximumEvidenceEntries = 8;
19+
const maximumEvidenceCharacters = 32 * 1024 * 1024;
20+
const excerptBytes = 6_000;
21+
const lineIndexStride = 1_024;
22+
23+
export interface AgentMergeCIEvidence {
24+
readonly id: string;
25+
readonly scope: string;
26+
readonly runAttempt: number;
27+
readonly job: GitHubWorkflowJob;
28+
readonly log: GitHubWorkflowLog;
29+
readonly lineCount: number;
30+
/** One UTF-16 offset per 1,024 lines bounds seeking without a full per-line index. */
31+
readonly lineStartOffsets: Uint32Array;
32+
readonly expiresAt: number;
33+
}
34+
35+
export interface AgentMergeCIContinuation {
36+
readonly request: AgentMergeCIRequest;
37+
readonly summaryOffset?: number;
38+
readonly signature?: string;
39+
}
40+
41+
/** Holds only redacted, immutable evidence; authorization is rechecked by the tool before every use. */
42+
export class AgentMergeCIEvidenceStore extends Disposable {
43+
private readonly _entries = new Map<string, AgentMergeCIEvidence>();
44+
private readonly _listeners = this._register(new DisposableMap<string>());
45+
private readonly _cursors = new LRUCache<string, AgentMergeCIContinuation & { scope: string; expiresAt: number }>(128);
46+
private readonly _expiry = this._register(new RunOnceScheduler(() => this._prune(), evidenceLifetime));
47+
private _characters = 0;
48+
49+
find(scope: string, jobId: string, runId: string, runAttempt: number): AgentMergeCIEvidence | undefined {
50+
this._prune();
51+
const entry = [...this._entries.values()].find(entry => entry.scope === scope && entry.job.id === jobId && entry.job.runId === runId && entry.runAttempt === runAttempt);
52+
if (!entry) {
53+
return undefined;
54+
}
55+
// Reused evidence must not expire while its new summary is still being assembled.
56+
const refreshed = { ...entry, expiresAt: Date.now() + evidenceLifetime };
57+
this._entries.delete(entry.id);
58+
this._entries.set(entry.id, refreshed);
59+
return refreshed;
60+
}
61+
62+
get(id: string, scope: string): AgentMergeCIEvidence {
63+
this._prune();
64+
const entry = this._entries.get(id);
65+
if (!entry || entry.scope !== scope) {
66+
throw new Error('Invalid, expired, or unauthorized CI evidence ID. Read a new summary for the active Agent Merge turn.');
67+
}
68+
this._entries.delete(id);
69+
this._entries.set(id, entry);
70+
return entry;
71+
}
72+
73+
canAdd(retainedIds: ReadonlySet<string>, characters = 0): boolean {
74+
const retained = [...this._entries.values()].filter(entry => retainedIds.has(entry.id));
75+
return retained.length < maximumEvidenceEntries
76+
&& retained.reduce((total, entry) => total + entry.log.text.length, characters) <= maximumEvidenceCharacters;
77+
}
78+
79+
/** Returns undefined when the log cannot fit without evicting evidence already advertised on this page. */
80+
tryAdd(scope: string, runAttempt: number, job: GitHubWorkflowJob, log: GitHubWorkflowLog, signal: AbortSignal, retainedIds: ReadonlySet<string> = new Set()): AgentMergeCIEvidence | undefined {
81+
signal.throwIfAborted();
82+
if (this._store.isDisposed || log.text.length > maximumEvidenceCharacters) {
83+
throw new Error('CI evidence storage is unavailable or its memory limit was exceeded.');
84+
}
85+
this._prune();
86+
if (!this.canAdd(retainedIds, log.text.length)) {
87+
return undefined;
88+
}
89+
while (this._entries.size >= maximumEvidenceEntries || this._characters + log.text.length > maximumEvidenceCharacters) {
90+
this._delete([...this._entries.keys()].find(id => !retainedIds.has(id))!);
91+
}
92+
const entry: AgentMergeCIEvidence = {
93+
id: generateUuid(), scope, runAttempt,
94+
job: { id: job.id, runId: job.runId, name: job.name.slice(0, 200), headSha: job.headSha, runAttempt: job.runAttempt, checkRunId: job.checkRunId },
95+
log: { ...log },
96+
...indexLines(log.text),
97+
expiresAt: Date.now() + evidenceLifetime,
98+
};
99+
this._entries.set(entry.id, entry);
100+
this._characters += log.text.length;
101+
this._listeners.set(entry.id, Event.once(Event.fromDOMEventEmitter(signal, 'abort'))(() => this._delete(entry.id)));
102+
this._expiry.schedule();
103+
return entry;
104+
}
105+
106+
continue(scope: string, continuation: AgentMergeCIContinuation): { cursor: string } {
107+
const cursor = generateUuid();
108+
this._cursors.set(cursor, { ...continuation, scope, expiresAt: Date.now() + evidenceLifetime });
109+
this._expiry.schedule();
110+
return { cursor };
111+
}
112+
113+
resolve(cursor: string, scope: string): AgentMergeCIContinuation {
114+
this._prune();
115+
const continuation = this._cursors.get(cursor);
116+
if (!continuation || continuation.scope !== scope) {
117+
throw new Error('Invalid, expired, or unauthorized CI cursor. Read a new summary for the active Agent Merge turn.');
118+
}
119+
return continuation;
120+
}
121+
122+
private _delete(id: string): void {
123+
const entry = this._entries.get(id);
124+
if (entry) {
125+
this._characters -= entry.log.text.length;
126+
this._entries.delete(id);
127+
this._listeners.deleteAndDispose(id);
128+
}
129+
}
130+
131+
private _prune(): void {
132+
for (const entry of this._entries.values()) {
133+
if (entry.expiresAt <= Date.now()) {
134+
this._delete(entry.id);
135+
}
136+
}
137+
for (const [id, cursor] of [...this._cursors]) {
138+
if (cursor.expiresAt <= Date.now()) {
139+
this._cursors.delete(id);
140+
}
141+
}
142+
if (this._entries.size || this._cursors.size) {
143+
this._expiry.schedule();
144+
}
145+
}
146+
147+
override dispose(): void {
148+
this._entries.clear();
149+
this._cursors.clear();
150+
this._characters = 0;
151+
super.dispose();
152+
}
153+
}
154+
155+
interface CILine {
156+
readonly line: number;
157+
readonly column: number;
158+
readonly text: string;
159+
}
160+
161+
export interface CIExcerpt {
162+
readonly lines: readonly CILine[];
163+
readonly next?: AgentMergeCIRequest;
164+
readonly previous?: AgentMergeCIRequest;
165+
}
166+
167+
export function ciJsonBytes(value: object): number {
168+
return VSBuffer.fromString(JSON.stringify(value)).byteLength;
169+
}
170+
171+
function indexLines(text: string): Pick<AgentMergeCIEvidence, 'lineCount' | 'lineStartOffsets'> {
172+
const offsets: number[] = [];
173+
let lineCount = 0;
174+
for (let offset = 0; offset < text.length; lineCount++) {
175+
if (lineCount % lineIndexStride === 0) {
176+
offsets.push(offset);
177+
}
178+
const end = text.indexOf('\n', offset);
179+
offset = end < 0 ? text.length : end + 1;
180+
}
181+
return { lineCount, lineStartOffsets: Uint32Array.from(offsets) };
182+
}
183+
184+
function* linesInRange(entry: AgentMergeCIEvidence, first: number, last: number): Iterable<{ line: number; start: number; end: number }> {
185+
const text = entry.log.text;
186+
const checkpoint = Math.floor((first - 1) / lineIndexStride);
187+
let line = checkpoint * lineIndexStride + 1;
188+
for (let start = entry.lineStartOffsets[checkpoint] ?? text.length; start < text.length && line <= last; line++) {
189+
const newline = text.indexOf('\n', start);
190+
const end = newline < 0 ? text.length : newline;
191+
if (line >= first) {
192+
yield { line, start, end: end > start && text[end - 1] === '\r' ? end - 1 : end };
193+
}
194+
start = end + 1;
195+
}
196+
}
197+
198+
export function readCIRange(entry: AgentMergeCIEvidence, request: AgentMergeCIRequest, budget = excerptBytes): CIExcerpt {
199+
const first = request.startLine ?? 1;
200+
const last = Math.min(request.endLine ?? first + 199, entry.lineCount);
201+
if (first > Math.max(1, entry.lineCount)) {
202+
throw new Error('The requested line is outside the captured CI evidence. The total line count is unknown when complete is false.');
203+
}
204+
const result: CILine[] = [];
205+
let remaining = budget;
206+
for (const line of linesInRange(entry, first, last)) {
207+
let column = line.line === first ? request.startColumn ?? 1 : 1;
208+
if (column > Math.max(1, line.end - line.start)) {
209+
throw new Error('The requested column is outside the captured CI line.');
210+
}
211+
do {
212+
const text = entry.log.text.slice(line.start + column - 1, Math.min(line.end, line.start + column - 1 + 500));
213+
const part = { line: line.line, column, text };
214+
const bytes = ciJsonBytes(part) + 1;
215+
if (bytes > remaining || result.length >= 200) {
216+
return { lines: result, next: { mode: 'range', evidenceId: entry.id, startLine: line.line, startColumn: column, endLine: last } };
217+
}
218+
result.push(part);
219+
remaining -= bytes;
220+
column += text.length;
221+
} while (line.start + column - 1 < line.end);
222+
}
223+
return { lines: result };
224+
}
225+
226+
export function readCITail(entry: AgentMergeCIEvidence, lineCount = 100, budget = excerptBytes): CIExcerpt {
227+
const candidates = [...linesInRange(entry, Math.max(1, entry.lineCount - lineCount + 1), entry.lineCount)];
228+
const result: CILine[] = [];
229+
let remaining = budget;
230+
for (const line of candidates.reverse()) {
231+
let end = line.end;
232+
do {
233+
const start = Math.max(line.start, end - 200);
234+
const part = { line: line.line, column: start - line.start + 1, text: entry.log.text.slice(start, end) };
235+
const bytes = ciJsonBytes(part) + 1;
236+
if (bytes > remaining || result.length >= 200) {
237+
return {
238+
lines: result.reverse(),
239+
previous: { mode: 'range', evidenceId: entry.id, startLine: Math.max(1, line.line - 199), endLine: line.line },
240+
};
241+
}
242+
result.push(part);
243+
remaining -= bytes;
244+
end = start;
245+
} while (end > line.start);
246+
}
247+
const first = result.at(-1)?.line ?? 1;
248+
return {
249+
lines: result.reverse(),
250+
previous: first > 1 ? { mode: 'range', evidenceId: entry.id, startLine: Math.max(1, first - 200), endLine: first - 1 } : undefined,
251+
};
252+
}
253+
254+
export function searchCIEvidence(entry: AgentMergeCIEvidence, request: AgentMergeCIRequest) {
255+
const query = new RegExp(escapeRegExpCharacters(request.query!), 'i');
256+
const first = request.startLine ?? 1;
257+
if (first > Math.max(1, entry.lineCount)) {
258+
throw new Error('The search start line is outside the captured CI evidence.');
259+
}
260+
const context = request.contextLines ?? 2;
261+
const matches: { line: number; excerpt: readonly CILine[]; read: AgentMergeCIRequest }[] = [];
262+
let bytes = 0;
263+
let scannedThrough = first - 1;
264+
for (const line of linesInRange(entry, first, Math.min(entry.lineCount, first + 49_999))) {
265+
if (matches.length >= 5) {
266+
break;
267+
}
268+
const text = entry.log.text.slice(line.start, line.end);
269+
const index = text.search(query);
270+
if (index >= 0) {
271+
const excerpt: CILine[] = [];
272+
for (const surrounding of linesInRange(entry, Math.max(1, line.line - context), Math.min(entry.lineCount, line.line + context))) {
273+
const column = surrounding.line === line.line ? index + 1 : 1;
274+
const length = surrounding.line === line.line ? 200 : 60;
275+
excerpt.push({ line: surrounding.line, column, text: entry.log.text.slice(surrounding.start + column - 1, Math.min(surrounding.end, surrounding.start + column - 1 + length)) });
276+
}
277+
const match = {
278+
line: line.line, excerpt,
279+
read: { mode: 'range' as const, evidenceId: entry.id, startLine: line.line, startColumn: index + 1, endLine: line.line },
280+
};
281+
const size = ciJsonBytes(match);
282+
if (bytes + size > 8_000 && matches.length) {
283+
break;
284+
}
285+
matches.push(match);
286+
bytes += size;
287+
}
288+
scannedThrough = line.line;
289+
}
290+
return {
291+
matches,
292+
scannedThrough,
293+
next: scannedThrough < entry.lineCount ? { ...request, startLine: scannedThrough + 1 } : undefined,
294+
};
295+
}
296+
297+
export function ciFailureExcerpt(entry: AgentMergeCIEvidence): readonly CILine[] {
298+
const result: CILine[] = [];
299+
for (const line of linesInRange(entry, 1, entry.lineCount)) {
300+
const text = entry.log.text.slice(line.start, line.end);
301+
const index = text.search(/\b(?:[1-9]\d* failing|failed|AssertionError|Error|FAIL(?:URE|ED)?)\b|##\[error\]/i);
302+
if (index >= 0) {
303+
const column = Math.max(1, index - 40 + 1);
304+
result.push({ line: line.line, column, text: text.slice(column - 1, column - 1 + 200) });
305+
if (result.length > 3) {
306+
result.shift();
307+
}
308+
}
309+
}
310+
return result;
311+
}
312+
313+
export function ciEvidenceMetadata(entry: AgentMergeCIEvidence) {
314+
return {
315+
evidenceId: entry.id,
316+
runId: entry.job.runId,
317+
runAttempt: entry.runAttempt,
318+
jobId: entry.job.id,
319+
jobHeadSha: entry.job.headSha ?? null,
320+
jobRunAttempt: entry.job.runAttempt ?? null,
321+
complete: !entry.log.truncated,
322+
capturedLines: entry.lineCount,
323+
lineNumbering: 'One-based lines and UTF-16 columns in the cached redacted text.',
324+
totalLines: entry.log.truncated ? null : entry.lineCount,
325+
bytesRead: entry.log.bytesRead ?? null,
326+
maximumBytes: entry.log.maximumBytes ?? null,
327+
terminalLimit: entry.log.truncated ? 'Download stopped before EOF. Only the captured prefix is available; the true tail and uncaptured lines are unavailable. Repeating this read cannot extend the download limit.' : null,
328+
expiresAt: new Date(entry.expiresAt).toISOString(),
329+
};
330+
}

0 commit comments

Comments
 (0)