fix: classify transient GitHub connectivity

Merge PR #102 after rebasing onto current master and validating focused GitHub transient/Code Queue preflight behavior. GitHub DNS/API failures now classify as retryable github-transient with retry/backoff or keep-fresh-heartbeat-task guidance.
This commit is contained in:
Lyon
2026-05-23 15:49:29 +08:00
committed by GitHub
parent 10bb228df5
commit 55c1b296ff
7 changed files with 292 additions and 14 deletions
@@ -546,6 +546,69 @@ async function main(): Promise<void> {
assertCondition(proxyGapRecord.failureKind === "proxy-gap", "proxy failures should classify as proxy-gap", proxyGapRecord);
assertCondition(proxyGapRecord.degradedReason === "configured GitHub egress proxy host is not resolvable: missing-egress-proxy.unidesk.svc.cluster.local", "proxy degraded reason should point at the proxy", proxyGapRecord);
const githubTransientContract = await codexPrPreflightQueryForTest(["--remote"], {
config: null,
coreFetch: () => ({
ok: true,
status: 200,
body: {
runtimePreflight: rawRuntimePreflightFixture({
ok: false,
pullRequestDelivery: {
...asRecord(rawRuntimePreflightFixture().pullRequestDelivery),
ok: false,
credentials: {
ghTokenPresent: true,
githubTokenPresent: false,
ghHostsConfigPresent: false,
gitCredentialsPresent: false,
},
egress: {
proxy: {
selectedProxyHost: "d601-provider-egress-proxy.unidesk.svc.cluster.local",
selectedProxyPort: "18789",
selectedProxyHostResolvable: true,
},
githubDefault: { command: "curl", args: ["-IsS", "https://github.com"], ok: false, exitCode: 6, signal: null, error: null, stdout: "", stderr: "curl: (6) Could not resolve host: github.com" },
apiDefault: { command: "curl", args: ["-IsS", "https://api.github.com"], ok: false, exitCode: 6, signal: null, error: null, stdout: "", stderr: "curl: (6) Could not resolve host: api.github.com" },
issueApi: null,
},
remote: {
gitLsRemote: { command: "git", args: ["ls-remote", "--heads", "origin", "master"], ok: false, exitCode: 128, signal: null, error: null, stdout: "", stderr: "ssh: Could not resolve hostname github.com: Temporary failure in name resolution" },
gitHttpsLsRemote: null,
githubSshAuthenticated: false,
ghAuthStatus: { command: "gh", args: ["auth", "status"], ok: false, exitCode: 1, signal: null, error: null, stdout: "", stderr: "error connecting to api.github.com" },
ghRepoView: null,
ghIssueView: null,
ghPrReadOnly: null,
},
limitations: [
"GitHub HTTPS probe failed with the default environment/proxy",
"GitHub API probe failed with the default environment/proxy",
"git ls-remote origin master failed",
],
risks: [],
},
}),
},
}),
});
const githubTransientRecord = asRecord(githubTransientContract);
assertCondition(githubTransientRecord.failureKind === "github-transient", "GitHub DNS/API failures should classify separately from auth and semantic failures", githubTransientRecord);
assertCondition(githubTransientRecord.degradedReason === "github-dns-api-transient", "GitHub transient degraded reason should be stable", githubTransientRecord);
assertCondition(githubTransientRecord.runnerDisposition === "infra-blocked", "GitHub transient keeps infra-blocked disposition for legacy callers", githubTransientRecord);
assertCondition(githubTransientRecord.retryable === true, "GitHub transient should expose top-level retryable=true", githubTransientRecord);
assertCondition(githubTransientRecord.commanderAction === "retry-backoff-or-keep-running-if-heartbeat-fresh", "GitHub transient should expose top-level commander action", githubTransientRecord);
const githubTransientPreflight = asRecord(githubTransientRecord.preflight);
assertCondition(githubTransientPreflight.retryable === true, "GitHub transient preflight should be retryable", githubTransientPreflight);
assertCondition(githubTransientPreflight.commanderAction === "retry-backoff-or-keep-running-if-heartbeat-fresh", "GitHub transient should expose bounded commander action", githubTransientPreflight);
const githubTransient = asRecord(githubTransientPreflight.githubTransient);
assertCondition(githubTransient.kind === "github-transient", "GitHub transient evidence should identify kind", githubTransient);
assertCondition(githubTransient.notAuthMissing === true, "GitHub transient must not be auth-missing", githubTransient);
assertCondition(githubTransient.notPrSemanticFailure === true, "GitHub transient must not be PR semantic failure", githubTransient);
assertCondition(Array.isArray(githubTransient.failedProbes) && githubTransient.failedProbes.length <= 4, "GitHub transient evidence should stay bounded", githubTransient);
assertCondition(String(githubTransient.commanderAction ?? "").includes("keep the task running"), "GitHub transient action should preserve fresh-heartbeat tasks", githubTransient);
let observedDryRunPath = "";
const dryRunContract = await codexPrPreflightQueryForTest([
"--remote",
@@ -820,6 +883,7 @@ async function main(): Promise<void> {
"local backend-core absence returns runner-local-observation-gap",
"auth missing returns auth-missing with broker/auth-broker-needed",
"proxy failures return proxy-gap",
"GitHub DNS/API failures return github-transient with retry/backoff guidance",
"git remote failures return git-remote-gap",
"combined push/PR create dry-run contract stays read-only and separates system gh from UniDesk gh CLI",
"broker-issued token, env-token, and missing-token branches expose authBroker source/capability/nextAction",
+34
View File
@@ -202,6 +202,21 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque
};
}
async function startResetGitHub(): Promise<{ baseUrl: string; close: () => Promise<void> }> {
const server = createServer((req) => {
req.socket.destroy();
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
assertCondition(typeof address === "object" && address !== null, "reset mock server should expose address");
const port = (address as AddressInfo).port;
assertCondition(typeof port === "number", "reset mock server should expose port");
return {
baseUrl: `http://127.0.0.1:${port}`,
close: () => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())),
};
}
function dataOf(response: JsonRecord): JsonRecord {
assertCondition(response.ok === true, "CLI command should succeed", response);
assertCondition(typeof response.data === "object" && response.data !== null && !Array.isArray(response.data), "response data should be object", response);
@@ -383,6 +398,24 @@ export async function runGhCliPrContract(): Promise<JsonRecord> {
await mock.close();
}
const resetMock = await startResetGitHub();
try {
const transient = await runCli(["gh", "auth", "status", "--repo", "pikasTech/unidesk"], {
GH_TOKEN: "contract-token",
UNIDESK_GITHUB_API_URL: resetMock.baseUrl,
});
assertCondition(transient.status !== 0, "GitHub DNS/API transient should fail structurally", transient.json ?? { stdout: transient.stdout });
const transientData = failedDataOf(transient.json ?? {});
assertCondition(transientData.degradedReason === "github-transient", "GitHub DNS/API transient should not be auth-missing or semantic failure", transientData);
assertCondition(transientData.runnerDisposition === "infra-blocked", "GitHub transient should remain infra-blocked", transientData);
const transientDetails = transientData.details as JsonRecord;
assertCondition(transientDetails.retryable === true, "GitHub transient should be retryable", transientDetails);
assertCondition(transientDetails.commanderAction === "retry-backoff-or-keep-running-if-heartbeat-fresh", "GitHub transient should expose bounded commander action", transientDetails);
assertCondition(!transient.stdout.includes("contract-token"), "GitHub transient output must not print token values", { stdout: transient.stdout });
} finally {
await resetMock.close();
}
const title = "contract pr create";
const bodyFile = join(tmpdir(), `unidesk-gh-pr-contract-${process.pid}.md`);
writeFileSync(bodyFile, "Line 1\n`code`\n| a | b |\n", "utf8");
@@ -519,6 +552,7 @@ export async function runGhCliPrContract(): Promise<JsonRecord> {
"pr read/view accept owner/repo#number shorthand and reject conflicting --repo",
"pr read/view --raw is explicit full disclosure",
"pr read normalizes open and merged lifecycle fields from REST",
"GitHub DNS/API transients are retryable and distinct from auth or PR semantic failures",
"pr view closeout metadata fields are accepted and hydrated through GraphQL",
"pr read unsupported fields fail structurally with supported closeout fields listed",
"pr create dry-run exposes planned operation",
+81 -7
View File
@@ -320,7 +320,7 @@ interface CodexSkillsSyncOptions {
full: boolean;
}
type CodeQueuePrPreflightFailureKind = "auth-missing" | "runner-skills-blocker" | "proxy-gap" | "git-remote-gap" | "control-plane-missing" | "target-stack-not-running";
type CodeQueuePrPreflightFailureKind = "auth-missing" | "runner-skills-blocker" | "github-transient" | "proxy-gap" | "git-remote-gap" | "control-plane-missing" | "target-stack-not-running";
type CodeQueueObservationGapKind = "runner-local-observation-gap" | "control-plane-observation-gap" | null;
interface CodeQueuePrPreflightTransport {
@@ -3817,6 +3817,64 @@ function compactCommandProbe(value: unknown): Record<string, unknown> | null {
};
}
function commandProbeFailureText(value: unknown): string {
const probe = asRecord(value);
if (probe === null || probe.ok === true) return "";
const args = Array.isArray(probe.args) ? probe.args.map(String).join(" ") : "";
return [
typeof probe.command === "string" ? probe.command : "",
args,
typeof probe.error === "string" ? probe.error : "",
typeof probe.stderr === "string" ? probe.stderr : "",
typeof probe.stdout === "string" ? probe.stdout : "",
].filter((item) => item.length > 0).join("\n");
}
function commandProbeLooksLikeGithubTransient(value: unknown): boolean {
const text = commandProbeFailureText(value);
if (text.length === 0) return false;
if (/\bproxy\b/iu.test(text) && /could not resolve|connect|tunnel|proxy/iu.test(text)) return false;
return /temporary failure in name resolution|error connecting to api\.github\.com|getaddrinfo|could not resolve host:? (github\.com|api\.github\.com)|name or service not known|eai_again|enotfound|etimedout|econnreset|ehostunreach|enetunreach|failed to connect to (github\.com|api\.github\.com)|network is unreachable/iu.test(text);
}
function githubTransientEvidence(pull: Record<string, unknown>): Record<string, unknown> | null {
const egress = asRecord(pull.egress) ?? {};
const remote = asRecord(pull.remote);
const probes: Array<[string, unknown]> = [
["egress.githubDefault", egress.githubDefault],
["egress.apiDefault", egress.apiDefault],
["egress.issueApi", egress.issueApi],
];
if (remote !== null) {
probes.push(
["remote.gitLsRemote", remote.gitLsRemote],
["remote.gitHttpsLsRemote", remote.gitHttpsLsRemote],
["remote.ghAuthStatus", remote.ghAuthStatus],
["remote.ghRepoView", remote.ghRepoView],
["remote.ghIssueView", remote.ghIssueView],
["remote.ghPrReadOnly", remote.ghPrReadOnly],
);
}
const failedProbes = probes
.map(([name, probe]) => ({ name, probe, text: commandProbeFailureText(probe) }))
.filter((item) => commandProbeLooksLikeGithubTransient(item.probe))
.slice(0, 4);
if (failedProbes.length === 0) return null;
return {
kind: "github-transient",
retryable: true,
scope: "github-dns-api",
failedProbes: failedProbes.map((item) => ({
name: item.name,
preview: compactInlinePreview(item.text, 240),
})),
commanderAction: "retry/backoff; if Code Queue heartbeat or trace is fresh, keep the task running and continue supervision",
notAuthMissing: true,
notPrSemanticFailure: true,
valuesPrinted: false,
};
}
function compactToolStatus(value: unknown): Record<string, unknown> {
const tool = asRecord(value) ?? {};
return {
@@ -4183,21 +4241,26 @@ function compactPrRuntimePreflight(preflight: Record<string, unknown>, options:
const limitations = Array.isArray(pull.limitations) ? pull.limitations.map(String) : [];
const risks = Array.isArray(pull.risks) ? pull.risks.map(String) : [];
const ok = preflight.ok === true && tokenCoverage.ok === true;
const githubTransient = githubTransientEvidence(pull);
const skillsBlocked = skills !== null && skills.ok !== true;
const skillsSyncBlocked = skillsSync !== null && skillsSync.ok !== true;
const failureKind = !tokenCoverage.ok
? "auth-missing"
: skillsBlocked || skillsSyncBlocked
? "runner-skills-blocker"
: limitations.some((item) => item.includes("git ls-remote") || item.includes("git push --dry-run failed"))
? "git-remote-gap"
: !preflight.ok
? "proxy-gap"
: null;
: githubTransient !== null
? "github-transient"
: limitations.some((item) => item.includes("git ls-remote") || item.includes("git push --dry-run failed"))
? "git-remote-gap"
: !preflight.ok
? "proxy-gap"
: null;
const degradedReason = failureKind === "auth-missing"
? "auth-broker-needed"
: failureKind === "runner-skills-blocker"
? typeof skillsSync?.blocker === "string" ? skillsSync.blocker : typeof skills?.blocker === "string" ? skills.blocker : "runner-skills-degraded"
: failureKind === "github-transient"
? "github-dns-api-transient"
: failureKind === "git-remote-gap"
? "git remote probe failed"
: failureKind === "proxy-gap"
@@ -4326,12 +4389,19 @@ function compactPrRuntimePreflight(preflight: Record<string, unknown>, options:
limitations,
risks,
runnerDisposition: ok ? "ready" : "infra-blocked",
...(githubTransient === null ? {} : {
retryable: true,
commanderAction: "retry-backoff-or-keep-running-if-heartbeat-fresh",
githubTransient,
}),
activeRunnerDevContainer,
recoveryHint: ok
? tokenCoverage.source === "auth-broker"
? "Runner PR workflow has auth-broker coverage for GitHub REST preflight; real PR creation still requires commander authorization."
: "Runner PR workflow has env-token coverage for the scheduler."
: "Scheduler preflight lacks GitHub auth coverage for scheduler-scoped admission only. First verify the active task with repo-native gh auth status and PR create dry-run; long-term configure auth-broker or inject GH_TOKEN/GITHUB_TOKEN into the scheduler runtime secret.",
: githubTransient !== null
? "GitHub DNS/API connectivity failed transiently. Retry with backoff; if the task heartbeat or trace is fresh, keep supervising the running task instead of closing or requeueing business work."
: "Scheduler preflight lacks GitHub auth coverage for scheduler-scoped admission only. First verify the active task with repo-native gh auth status and PR create dry-run; long-term configure auth-broker or inject GH_TOKEN/GITHUB_TOKEN into the scheduler runtime secret.",
commands: {
local: "bun scripts/cli.ts gh auth status --repo pikasTech/unidesk",
runner: "bun scripts/cli.ts codex pr-preflight --remote",
@@ -4582,6 +4652,8 @@ function codeQueuePrPreflight(optionArgs: string[] = [], transport: CodeQueuePrP
runnerDisposition: compact.runnerDisposition,
failureKind: compact.failureKind ?? null,
degradedReason: compact.degradedReason ?? null,
...(compact.retryable === true ? { retryable: true } : {}),
...(typeof compact.commanderAction === "string" ? { commanderAction: compact.commanderAction } : {}),
authScopeSummary: compact.authScopeSummary,
scopeBoundary: compact.scopeBoundary,
activeRunnerDevContainer: compact.activeRunnerDevContainer,
@@ -4638,6 +4710,8 @@ export async function codexPrPreflightQueryAsync(optionArgs: string[], fetcher:
runnerDisposition: compact.runnerDisposition,
failureKind: compact.failureKind ?? null,
degradedReason: compact.degradedReason ?? null,
...(compact.retryable === true ? { retryable: true } : {}),
...(typeof compact.commanderAction === "string" ? { commanderAction: compact.commanderAction } : {}),
authScopeSummary: compact.authScopeSummary,
scopeBoundary: compact.scopeBoundary,
activeRunnerDevContainer: compact.activeRunnerDevContainer,
+108 -2
View File
@@ -77,6 +77,7 @@ type GitHubDegradedReason =
| "missing-token"
| "auth-failed"
| "egress-failed"
| "github-transient"
| "network-proxy-failed"
| "permission-denied"
| "repo-not-found"
@@ -359,6 +360,8 @@ interface GitHubErrorPayload {
runnerDisposition: RunnerDisposition;
status?: number;
message: string;
retryable?: boolean;
commanderAction?: string;
details?: unknown;
scopes?: {
accepted: string | null;
@@ -1347,6 +1350,97 @@ function sanitizedErrorDetails(parsed: unknown): unknown {
return details;
}
function errorProperty(value: unknown, key: string): string | null {
if (!isRecord(value)) return null;
const item = value[key];
return typeof item === "string" && item.length > 0 ? item : null;
}
function errorChainText(error: unknown): string {
const parts: string[] = [];
let current: unknown = error;
const seen = new Set<unknown>();
while (current !== null && current !== undefined && !seen.has(current)) {
seen.add(current);
if (current instanceof Error) {
parts.push(current.name, current.message);
current = (current as Error & { cause?: unknown }).cause;
continue;
}
if (isRecord(current)) {
for (const key of ["name", "message", "code", "errno", "syscall", "hostname", "host"]) {
const value = errorProperty(current, key);
if (value !== null) parts.push(value);
}
current = current.cause;
continue;
}
parts.push(String(current));
break;
}
return parts.join(" ").replace(/\s+/gu, " ").trim();
}
function firstErrorProperty(error: unknown, key: string): string | null {
let current: unknown = error;
const seen = new Set<unknown>();
while (current !== null && current !== undefined && !seen.has(current)) {
seen.add(current);
const value = errorProperty(current, key);
if (value !== null) return value;
current = isRecord(current) ? current.cause : current instanceof Error ? (current as Error & { cause?: unknown }).cause : null;
}
return null;
}
function classifyGitHubFetchFailure(error: unknown): {
reason: GitHubDegradedReason;
message: string;
retryable?: boolean;
commanderAction?: string;
details: Record<string, unknown>;
} {
const text = errorChainText(error);
const lower = text.toLowerCase();
const isProxyFailure = /\bproxy\b/u.test(lower) && /could not resolve|connect|tunnel|proxy/i.test(text);
const isGitHubTransient = !isProxyFailure && (
/temporary failure in name resolution/iu.test(text)
|| /\b(eai_again|enotfound|etimedout|econnreset|econnrefused|ehostunreach|enetunreach)\b/iu.test(text)
|| /getaddrinfo|could not resolve host|name or service not known|failed to connect|connection timed out|connection reset|connection closed|other side closed|network is unreachable|socket connection/iu.test(text)
);
if (!isGitHubTransient) {
return {
reason: "network-proxy-failed",
message: text.length > 0 ? text : "GitHub request failed before receiving an HTTP response",
details: {
scope: "github-api",
transient: false,
errorName: error instanceof Error ? error.name : firstErrorProperty(error, "name"),
errorCode: firstErrorProperty(error, "code"),
host: firstErrorProperty(error, "hostname") ?? firstErrorProperty(error, "host"),
},
};
}
return {
reason: "github-transient",
message: text.length > 0 ? text : "GitHub DNS/API connection failed before receiving an HTTP response",
retryable: true,
commanderAction: "retry-backoff-or-keep-running-if-heartbeat-fresh",
details: {
scope: "github-api",
transient: true,
retryable: true,
authFailure: false,
semanticFailure: false,
errorName: error instanceof Error ? error.name : firstErrorProperty(error, "name"),
errorCode: firstErrorProperty(error, "code"),
host: firstErrorProperty(error, "hostname") ?? firstErrorProperty(error, "host"),
commanderAction: "retry/backoff; if the Code Queue task heartbeat or trace is fresh, keep supervising instead of closing or requeueing business work",
valuesPrinted: false,
},
};
}
function errorPayload(reason: GitHubDegradedReason, message: string, extra: Omit<GitHubErrorPayload, "ok" | "degradedReason" | "runnerDisposition" | "message"> = {}): GitHubErrorPayload {
return {
ok: false,
@@ -1431,7 +1525,13 @@ async function githubRequest<T>(
body: body === undefined ? undefined : JSON.stringify(body),
});
} catch (error) {
return errorPayload("network-proxy-failed", error instanceof Error ? error.message : String(error), { request: { method, path } });
const classified = classifyGitHubFetchFailure(error);
return errorPayload(classified.reason, classified.message, {
request: { method, path },
details: classified.details,
retryable: classified.retryable,
commanderAction: classified.commanderAction,
});
} finally {
clearTimeout(timeout);
}
@@ -1473,7 +1573,13 @@ async function githubGraphqlRequest<T>(
body: JSON.stringify({ query, variables }),
});
} catch (error) {
return errorPayload("network-proxy-failed", error instanceof Error ? error.message : String(error), { request: { method: "POST", path: "/graphql" } });
const classified = classifyGitHubFetchFailure(error);
return errorPayload(classified.reason, classified.message, {
request: { method: "POST", path: "/graphql" },
details: classified.details,
retryable: classified.retryable,
commanderAction: classified.commanderAction,
});
} finally {
clearTimeout(timeout);
}