-
Notifications
You must be signed in to change notification settings - Fork 0
Orchestrator hang: fresh-task retry + richer error diagnostics #244
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
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 | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -91,6 +91,15 @@ const sleep = ms => new Promise(res => setTimeout(res, ms)); | |||||||||||||
| // ::workflow-command:: strings into the Actions log. | ||||||||||||||
| const safe = s => String(s ?? '').replace(/::/g, '::'); | ||||||||||||||
|
|
||||||||||||||
| // Node's fetch (undici) surfaces generic messages like "fetch failed" / | ||||||||||||||
| // "terminated" and puts the actual reason (ECONNRESET, UND_ERR_*, etc.) on | ||||||||||||||
| // e.cause. Surface both so error logs are diagnosable. | ||||||||||||||
| function errDetail(e) { | ||||||||||||||
| const c = e?.cause; | ||||||||||||||
| const cause = c ? ` | cause: ${c.code || c.message || c}` : ''; | ||||||||||||||
| return `${e?.message || e}${cause}`; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| // Strip a single markdown fence that wraps the *entire* response, while | ||||||||||||||
| // leaving any inner code/suggestion fences intact. | ||||||||||||||
| function stripOuterFence(text) { | ||||||||||||||
|
|
@@ -161,7 +170,13 @@ async function rpc(url, token, body) { | |||||||||||||
| body: JSON.stringify(body), | ||||||||||||||
|
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. 🛡️ Security — The fetch call sends an Authorization: Bearer token to whatever URL is configured. If ORCHESTRATOR_AGENT_URL (or persona URLs) are accidentally set to http://, the bearer token will traverse the network in plaintext, risking credential exposure or interception (especially in shared CI runners or through proxies).
|
||||||||||||||
| signal: AbortSignal.timeout(RPC_TIMEOUT_MS), | ||||||||||||||
| }); | ||||||||||||||
| if (!r.ok) throw new Error(`http ${r.status}`); | ||||||||||||||
| if (!r.ok) { | ||||||||||||||
| // Include statusText and a short body snippet — a 5xx from the gateway often | ||||||||||||||
|
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. 🛡️ Security — Including a snippet of the HTTP response body in error messages improves diagnostics but can leak sensitive server-side information (stack traces, internal hostnames/paths, or echoed request fragments) into public CI logs. On public PRs, this can reveal internal details of the orchestrator or upstream provider.
|
||||||||||||||
| // carries a reason (e.g. upstream timeout) that the bare status code hides. | ||||||||||||||
| let snippet = ''; | ||||||||||||||
| try { snippet = (await r.text()).replace(/\s+/g, ' ').trim().slice(0, 200); } catch { /* ignore */ } | ||||||||||||||
|
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. |
||||||||||||||
| throw new Error(`http ${r.status} ${r.statusText}${snippet ? ` — ${snippet}` : ''}`); | ||||||||||||||
|
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.
Suggested change
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.
|
||||||||||||||
| } | ||||||||||||||
| const json = await r.json(); | ||||||||||||||
| if (json.error) { | ||||||||||||||
| throw new Error(`json-rpc error ${json.error.code ?? '?'}: ${json.error.message ?? 'unknown'}`); | ||||||||||||||
|
|
@@ -177,8 +192,10 @@ function extractText(task) { | |||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| // Submit the review asynchronously and poll to completion. Returns the | ||||||||||||||
| // persona's markdown review, or null on failure. | ||||||||||||||
| async function reviewWithAgent({ core, title, url, token, messageText, msgId }) { | ||||||||||||||
| // persona's markdown review, or null on failure. `maxPollMs` bounds the poll | ||||||||||||||
|
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. |
||||||||||||||
| // window (defaults to MAX_POLL_MS; the orchestrator passes a shorter per-attempt | ||||||||||||||
| // budget so it can afford a fresh-task retry within the job timeout). | ||||||||||||||
| async function reviewWithAgent({ core, title, url, token, messageText, msgId, maxPollMs = MAX_POLL_MS }) { | ||||||||||||||
|
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. 🧪 Maintainability & Tests — The new maxPollMs parameter is a good addition. To improve readability and IDE hints, consider adding a brief JSDoc block documenting the parameter types and behavior, and link it to the orchestrator usage. Also, within this function, the submit-retry loop uses hard-coded values (3 attempts and a 5s incremental backoff) across multiple lines — extracting these into named constants will reduce drift and make logs self-consistent. Example: // Tunables for submit retry behavior.
const SUBMIT_ATTEMPTS = 3;
const SUBMIT_BACKOFF_MS = 5000;
// ...
for (let attempt = 1; attempt <= SUBMIT_ATTEMPTS; attempt++) { /* ... */ }
if (attempt < SUBMIT_ATTEMPTS) await sleep(attempt * SUBMIT_BACKOFF_MS);And reference SUBMIT_ATTEMPTS in the “could not submit after … attempts” log. |
||||||||||||||
| let taskId = null; | ||||||||||||||
| for (let attempt = 1; attempt <= 3; attempt++) { | ||||||||||||||
| try { | ||||||||||||||
|
|
@@ -193,37 +210,54 @@ async function reviewWithAgent({ core, title, url, token, messageText, msgId }) | |||||||||||||
| if (taskId) break; | ||||||||||||||
| core.info(` ${title}: submit returned no task id (attempt ${attempt})`); | ||||||||||||||
| } catch (e) { | ||||||||||||||
| core.info(` ${title}: submit attempt ${attempt} failed (${safe(e.message)})`); | ||||||||||||||
| core.info(` ${title}: submit attempt ${attempt} failed (${safe(errDetail(e))})`); | ||||||||||||||
| } | ||||||||||||||
| if (attempt < 3) await sleep(attempt * 5000); | ||||||||||||||
|
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. 🧪 Maintainability & Tests — The incremental backoff uses a hard-coded 5000ms step. Extract to a named constant (e.g., SUBMIT_BACKOFF_MS) with a short comment about why that value is chosen, to make future tuning clearer and avoid hidden magic numbers. |
||||||||||||||
| } | ||||||||||||||
| if (!taskId) return null; | ||||||||||||||
| if (!taskId) { | ||||||||||||||
| core.warning(`${title}: could not submit a task after 3 attempts`); | ||||||||||||||
|
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. 🧪 Maintainability & Tests — The log string hard-codes “3 attempts”, which can drift if the loop is ever changed. Tie this to a constant (e.g., SUBMIT_ATTEMPTS) and reuse it in both the loop guard and the message to keep them in sync. |
||||||||||||||
| return null; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| // Track poll outcomes so a timeout is diagnosable: a stuck task shows a | ||||||||||||||
| // non-terminal last state with healthy polls; a network problem shows poll errors. | ||||||||||||||
| const start = Date.now(); | ||||||||||||||
| while (Date.now() - start < MAX_POLL_MS) { | ||||||||||||||
| let polls = 0, pollErrs = 0, lastState = 'none'; | ||||||||||||||
| while (Date.now() - start < maxPollMs) { | ||||||||||||||
| await sleep(POLL_INTERVAL_MS); | ||||||||||||||
|
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. |
||||||||||||||
| let task; | ||||||||||||||
| try { | ||||||||||||||
| const g = await rpc(url, token, { jsonrpc: '2.0', id: `${msgId}-get`, method: 'tasks/get', params: { id: taskId } }); | ||||||||||||||
| task = g.result; | ||||||||||||||
| polls++; | ||||||||||||||
| } catch (e) { | ||||||||||||||
| core.info(` ${title}: poll error (${safe(e.message)}), will retry`); | ||||||||||||||
| pollErrs++; | ||||||||||||||
| core.info(` ${title}: poll error (${safe(errDetail(e))}), will retry`); | ||||||||||||||
|
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.
Suggested change
No big deal though if we want to keep it at info. |
||||||||||||||
| continue; | ||||||||||||||
| } | ||||||||||||||
| const state = task?.status?.state; | ||||||||||||||
| if (state) lastState = state; | ||||||||||||||
| if (state === 'completed') return stripOuterFence(extractText(task)); | ||||||||||||||
| if (state === 'failed' || state === 'canceled' || state === 'rejected') { | ||||||||||||||
|
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.
Suggested change
That’ll make failures more diagnosable without another repro. |
||||||||||||||
| core.warning(`${title}: task ${state}`); | ||||||||||||||
| return null; | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
| core.warning(`${title}: task did not complete within ${MAX_POLL_MS / 1000}s`); | ||||||||||||||
| core.warning(`${title}: task did not complete within ${maxPollMs / 1000}s ` + | ||||||||||||||
|
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. 🧪 Maintainability & Tests — The timeout warning now includes last state and poll counters, which is great. Consider also logging the taskId once at submission success (redacting if needed) so that timeouts and failures can be correlated with server-side logs. A single core.info at submission with taskId would help later investigations without cluttering every poll. 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.
Suggested change
|
||||||||||||||
| `(last state: ${lastState}, ${polls} polls ok, ${pollErrs} poll errors)`); | ||||||||||||||
| return null; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| // --- Orchestrator: cross-persona deduplication of inline comments --- | ||||||||||||||
| const ORCH_URL_ENV = 'ORCHESTRATOR_AGENT_URL'; | ||||||||||||||
| const ORCH_TOKEN_ENV = 'ORCHESTRATOR_AGENT_TOKEN'; | ||||||||||||||
| // A healthy orchestrator dedups in ~2-3 min; a much longer poll means the task | ||||||||||||||
| // is stuck server-side, not slow. So bound each attempt and resubmit a *fresh* | ||||||||||||||
| // task once — a stuck task usually doesn't recur on a new submission. Two | ||||||||||||||
| // attempts at this budget still fit comfortably inside the 30m job timeout | ||||||||||||||
| // (alongside the persona phase + posting). | ||||||||||||||
| const ORCH_ATTEMPTS = 2; | ||||||||||||||
|
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. 🧪 Maintainability & Tests — ORCH_ATTEMPTS and ORCH_POLL_MS are good abstractions. For maintainability and operational tuning without code changes, consider allowing environment overrides with sane defaults, e.g.: const ORCH_ATTEMPTS = Number(process.env.ORCHESTRATOR_ATTEMPTS || 2);
const ORCH_POLL_MS = Number(process.env.ORCHESTRATOR_POLL_MS || 7 * 60 * 1000);Document these env vars in a header comment. 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. 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.
|
||||||||||||||
| const ORCH_POLL_MS = 7 * 60 * 1000; | ||||||||||||||
|
|
||||||||||||||
| // Robustly extract the orchestrator's { clusters: [...] } JSON from its response | ||||||||||||||
| // (tolerates a ```json fence — single- or multi-line — or stray prose). Returns | ||||||||||||||
|
|
@@ -287,20 +321,39 @@ async function orchestrateDedup({ core, allInline }) { | |||||||||||||
| if (allInline.length < 2) return empty; // nothing to dedupe (need ≥2 comments) | ||||||||||||||
|
|
||||||||||||||
| try { | ||||||||||||||
| const msgId = `council-orchestrator-${process.env.GITHUB_RUN_ID}`; | ||||||||||||||
| const payload = JSON.stringify(allInline.map(c => | ||||||||||||||
| ({ id: c.id, persona: c.persona, file: c.file, line: c.line, body: c.body }))); | ||||||||||||||
| const messageText = | ||||||||||||||
| `Here are ${allInline.length} inline review comments from one pull request, as a JSON array. ` + | ||||||||||||||
| `Identify redundant duplicate groups and return the clusters JSON per your instructions.\n\n${payload}`; | ||||||||||||||
|
|
||||||||||||||
| const out = await reviewWithAgent({ core, title: 'Orchestrator', url, token, messageText, msgId }); | ||||||||||||||
| if (!out) { core.warning('Orchestrator: no response — posting all comments unfiltered'); return empty; } | ||||||||||||||
| const parsed = parseClusters(out); | ||||||||||||||
| if (!parsed) { core.warning('Orchestrator: unparseable response — posting all comments unfiltered'); return empty; } | ||||||||||||||
| return applyClusters({ core, allInline, parsed }); | ||||||||||||||
| // Up to ORCH_ATTEMPTS, each a *fresh* task bounded by ORCH_POLL_MS — so a | ||||||||||||||
| // task that hangs server-side OR returns garbled output gets a second shot. | ||||||||||||||
| const runKey = `${process.env.GITHUB_RUN_ID}-${process.env.GITHUB_RUN_ATTEMPT || '1'}`; | ||||||||||||||
| for (let attempt = 1; attempt <= ORCH_ATTEMPTS; attempt++) { | ||||||||||||||
|
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. 🧪 Maintainability & Tests — The fresh-task retry loop in orchestrateDedup is the main new behavior and should be unit tested. To make that straightforward, add a small dependency-injection seam so tests can stub the agent call without touching network/RPC. For example, accept a reviewFn parameter (defaulting to reviewWithAgent), or export reviewWithAgent and orchestrateDedup from a module where tests can inject a stub. That enables tests for: no response then success; unparseable then success; both attempts fail; poll timeout path. 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.
|
||||||||||||||
| // RUN_ATTEMPT + per-attempt suffix → a distinct msgId (hence a fresh | ||||||||||||||
| // server-side task) on every submission, including GitHub "Re-run" re-runs. | ||||||||||||||
| const msgId = `council-orchestrator-${runKey}-${attempt}`; | ||||||||||||||
| const out = await reviewWithAgent({ | ||||||||||||||
| core, title: `Orchestrator (attempt ${attempt}/${ORCH_ATTEMPTS})`, | ||||||||||||||
| url, token, messageText, msgId, maxPollMs: ORCH_POLL_MS, | ||||||||||||||
| }); | ||||||||||||||
| const last = attempt === ORCH_ATTEMPTS; | ||||||||||||||
| if (!out) { | ||||||||||||||
| if (!last) core.info('Orchestrator: no response — resubmitting a fresh task'); | ||||||||||||||
| continue; | ||||||||||||||
| } | ||||||||||||||
| const parsed = parseClusters(out); | ||||||||||||||
| if (!parsed) { | ||||||||||||||
| if (!last) core.info('Orchestrator: unparseable response — resubmitting a fresh task'); | ||||||||||||||
| continue; | ||||||||||||||
| } | ||||||||||||||
| return applyClusters({ core, allInline, parsed }); | ||||||||||||||
| } | ||||||||||||||
| core.warning('Orchestrator: no usable response after retries — posting all comments unfiltered'); | ||||||||||||||
| return empty; | ||||||||||||||
| } catch (e) { | ||||||||||||||
| core.warning(`Orchestrator: unexpected error (${safe(e.message)}) — posting all comments unfiltered`); | ||||||||||||||
| core.warning(`Orchestrator: unexpected error (${safe(errDetail(e))}) — posting all comments unfiltered`); | ||||||||||||||
| return empty; | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
|
|
@@ -363,7 +416,9 @@ module.exports = async ({ github, context, core }) => { | |||||||||||||
| // 3. Fan out to all configured personas in parallel; isolate failures. | ||||||||||||||
| core.info(`Reviewing PR #${pull_number} with ${active.length} persona(s): ${active.map(p => p.title).join(', ')}`); | ||||||||||||||
| const results = await Promise.all(active.map(async p => { | ||||||||||||||
| const msgId = `council-${p.key}-${pull_number}-${process.env.GITHUB_RUN_ID}`; | ||||||||||||||
| // Include RUN_ATTEMPT so a GitHub "Re-run" gets a fresh server-side task (the | ||||||||||||||
|
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. 🧪 Maintainability & Tests — Run key/msgId construction includes RUN_ATTEMPT here, and a similar runKey is built inside orchestrateDedup. Factor this into a tiny helper (e.g., getRunKey() or buildMsgId(prefix, parts...)) to avoid duplication and keep the shape consistent across personas and the orchestrator. |
||||||||||||||
| // run id alone is stable across re-runs and could return a stale cached task). | ||||||||||||||
| const msgId = `council-${p.key}-${pull_number}-${process.env.GITHUB_RUN_ID}-${process.env.GITHUB_RUN_ATTEMPT || '1'}`; | ||||||||||||||
|
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. |
||||||||||||||
| const review = await reviewWithAgent({ core, title: p.title, url: p.url, token: p.token, messageText, msgId }); | ||||||||||||||
| if (!review) { | ||||||||||||||
| core.warning(`${p.title}: no review produced, skipping`); | ||||||||||||||
|
|
@@ -427,7 +482,7 @@ module.exports = async ({ github, context, core }) => { | |||||||||||||
| try { | ||||||||||||||
| await github.rest.pulls.deleteReviewComment({ owner, repo, comment_id: c.id }); | ||||||||||||||
| } catch (e) { | ||||||||||||||
| core.info(` ${p.title}: could not delete inline #${c.id} (${safe(e.message)})`); | ||||||||||||||
| core.info(` ${p.title}: could not delete inline #${c.id} (${safe(errDetail(e))})`); | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
| let inline = 0; | ||||||||||||||
|
|
@@ -441,7 +496,7 @@ module.exports = async ({ github, context, core }) => { | |||||||||||||
| inline++; | ||||||||||||||
| } catch (e) { | ||||||||||||||
| // Line wasn't commentable after all — fold it into the summary so it isn't lost. | ||||||||||||||
| core.info(` ${p.title}: inline anchor failed at ${safe(f.file)}:${f.line} (${safe(e.message)}), folding into summary`); | ||||||||||||||
| core.info(` ${p.title}: inline anchor failed at ${safe(f.file)}:${f.line} (${safe(errDetail(e))}), folding into summary`); | ||||||||||||||
| folded.push(f); | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
|
|
@@ -471,7 +526,7 @@ module.exports = async ({ github, context, core }) => { | |||||||||||||
| } | ||||||||||||||
| summaries++; | ||||||||||||||
| } catch (e) { | ||||||||||||||
| core.warning(`${p.title}: failed to post/update summary (${safe(e.message)})`); | ||||||||||||||
| core.warning(`${p.title}: failed to post/update summary (${safe(errDetail(e))})`); | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
| core.info(`Done: ${summaries}/${active.length} summaries, ${inlineTotal} inline posted, ${dropped} duplicate(s) dropped on PR #${pull_number}`); | ||||||||||||||
|
|
||||||||||||||
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.
🧪 Maintainability & Tests — errDetail is useful for surfacing undici’s cause. For future debugging, consider optionally including e.stack when an env var (e.g., DEBUG_ERRORS=1) is set; this keeps default logs terse while making it easy to toggle deeper diagnostics when investigating intermittent failures: