Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

59 changes: 55 additions & 4 deletions actions/setup/js/copilot_harness.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@
* - On a fresh run (attempt 0 or after a `--continue`-auth fallback): the env-var token is
* genuinely absent or invalid. All further retries will produce the same failure, so the
* driver bails immediately.
* - Null-type tool_call errors (400 "Invalid type for '...tool_calls[N].type': ... got null")
* poison the conversation history. Retrying with `--continue` re-injects the same broken
* state on every subsequent attempt. The driver restarts fresh to discard the poisoned
* history and permanently disables `--continue` for the remainder of the run so the corrupt
* state can never be reloaded. Once `--continue` is disabled this way it is not re-enabled
* even if later retries produce output.
* - Retries use exponential backoff: 5s → 10s → 20s (capped at 60s).
* - Maximum 3 retry attempts after the initial run.
*
Expand Down Expand Up @@ -69,6 +75,14 @@ const MODEL_NOT_SUPPORTED_PATTERN = /The requested model is not supported/;
// On a fresh run the token is genuinely absent — retrying will not help.
const NO_AUTH_INFO_PATTERN = /No authentication information found/;

// Pattern to detect null-type tool_call error that poisons conversation history.
// Matches the Copilot API 400 error:
// "Invalid type for '...tool_calls[N].type': expected one of 'function', ..., but got null instead."
// The model emitted a malformed tool call with type: null. Retrying with --continue
// re-injects the same broken history, producing the same 400 on every subsequent attempt.
// A fresh restart is required to discard the poisoned history.
const NULL_TYPE_TOOL_CALL_PATTERN = /tool_calls\[.*?\]\.type.*null/;

/**
* @typedef {(path: import("node:fs").PathOrFileDescriptor, data: string | Uint8Array, options?: import("node:fs").WriteFileOptions) => void} AppendFileSyncLike
*/
Expand Down Expand Up @@ -125,6 +139,18 @@ function isNoAuthInfoError(output) {
return NO_AUTH_INFO_PATTERN.test(output);
}

/**
* Determines if the collected output contains a null-type tool_call error.
* This error occurs when the model emits a malformed tool call with type: null.
* The Copilot API rejects it with a 400, and retrying with --continue will re-inject
* the same broken history, causing the same failure on every subsequent attempt.
* @param {string} output - Collected stdout+stderr from the process
* @returns {boolean}
*/
function isNullTypeToolCallError(output) {
return NULL_TYPE_TOOL_CALL_PATTERN.test(output);
}

/**
* Sleep for a specified duration
* @param {number} ms - Duration in milliseconds
Expand Down Expand Up @@ -384,6 +410,9 @@ async function main() {
let scheduledExit2Retries = 0;
let scheduledExit2RetryAttempted = false;
let useContinueOnRetry = false;
// Once set to true, --continue is never re-enabled for the remainder of this run.
// This prevents a broken --continue recovery from resurrecting --continue on the next attempt.
let continueDisabledPermanently = false;
const driverStartTime = Date.now();

for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
Expand Down Expand Up @@ -411,18 +440,24 @@ async function main() {
// Retry whenever the session was partially executed (hasOutput), using --continue so that
// the Copilot CLI can continue from where it left off. CAPIError 400 is the well-known
// transient case, but any partial-execution failure is eligible for a continue retry.
// Exceptions: MCP policy errors, model-not-supported errors, and auth errors are persistent
// configuration issues — never retry.
// Exceptions:
// - MCP policy errors and model-not-supported errors are persistent configuration issues.
// - Auth errors trigger a one-time fallback to a fresh run; after that --continue is
// permanently disabled.
// - Null-type tool_call 400 errors poison conversation history — always restart fresh and
// permanently disable --continue so the corrupt state is never reloaded.
const isCAPIError = isTransientCAPIError(result.output);
const isMCPPolicy = isMCPPolicyError(result.output);
const isModelNotSupported = isModelNotSupportedError(result.output);
const isAuthErr = isNoAuthInfoError(result.output);
const isNullTypeToolCall = isNullTypeToolCallError(result.output);
log(
`attempt ${attempt + 1} failed:` +
` exitCode=${result.exitCode}` +
` isCAPIError400=${isCAPIError}` +
` isMCPPolicyError=${isMCPPolicy}` +
` isModelNotSupportedError=${isModelNotSupported}` +
` isNullTypeToolCallError=${isNullTypeToolCall}` +
` isAuthError=${isAuthErr}` +
` hasOutput=${result.hasOutput}` +
` retriesRemaining=${MAX_RETRIES - attempt}`
Expand All @@ -448,13 +483,28 @@ async function main() {
if (isAuthErr) {
if (useContinueOnRetry && attempt < MAX_RETRIES) {
useContinueOnRetry = false;
continueDisabledPermanently = true;
log(`attempt ${attempt + 1}: auth error on --continue — retrying as fresh run (session credential may be corrupted; context will be lost)`);
continue;
}
log(`attempt ${attempt + 1}: no authentication information found — not retrying (COPILOT_GITHUB_TOKEN, GH_TOKEN, and GITHUB_TOKEN are all absent or invalid)`);
break;
}

// Null-type tool_call error: the model emitted a malformed tool call that poisons the
// conversation history. Retrying with --continue re-injects the same broken history and
// produces the same 400 on every subsequent attempt. Restart fresh to discard the poisoned
// history, and permanently disable --continue so the corrupt state is never re-loaded.
if (isNullTypeToolCall) {
Comment on lines +494 to +498
if (attempt < MAX_RETRIES && result.hasOutput) {
const priorMode = attempt > 0 && useContinueOnRetry ? "--continue" : "fresh run";
useContinueOnRetry = false;
continueDisabledPermanently = true;
log(`attempt ${attempt + 1}: null-type tool_call error (${priorMode}) — restarting fresh (poisoned history discarded; --continue disabled permanently)`);
continue;
}
}

// Scheduled runs: retry once on exit code 2 even when no output was produced.
// This specifically targets transient Copilot API outages at startup where there is no
// partial session state to continue from.
Expand All @@ -471,8 +521,9 @@ async function main() {

if (attempt < MAX_RETRIES && result.hasOutput) {
const reason = isCAPIError ? "CAPIError 400 (transient)" : "partial execution";
useContinueOnRetry = true;
log(`attempt ${attempt + 1}: ${reason} — will retry with --continue (attempt ${attempt + 2}/${MAX_RETRIES + 1})`);
useContinueOnRetry = !continueDisabledPermanently;
const retryMode = useContinueOnRetry ? "--continue" : "fresh run (--continue permanently disabled)";
log(`attempt ${attempt + 1}: ${reason} — will retry with ${retryMode} (attempt ${attempt + 2}/${MAX_RETRIES + 1})`);
continue;
}

Expand Down
200 changes: 200 additions & 0 deletions actions/setup/js/copilot_harness.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,206 @@ describe("copilot_harness.cjs", () => {
});
});

describe("null-type tool_call detection pattern", () => {
const NULL_TYPE_TOOL_CALL_PATTERN = /tool_calls\[.*?\]\.type.*null/;

it("matches the error format observed in failed workflow runs", () => {
const errorOutput = "Execution failed: CAPIError: 400 Invalid type for 'messages[45].tool_calls[0].type': expected one of 'function', 'all...ols', or 'custom', but got null instead.";
expect(NULL_TYPE_TOOL_CALL_PATTERN.test(errorOutput)).toBe(true);
});

it("matches with different array indices", () => {
expect(NULL_TYPE_TOOL_CALL_PATTERN.test("tool_calls[0].type: null")).toBe(true);
expect(NULL_TYPE_TOOL_CALL_PATTERN.test("tool_calls[12].type, got null")).toBe(true);
});

it("does not match unrelated tool_calls errors", () => {
expect(NULL_TYPE_TOOL_CALL_PATTERN.test("tool_calls[0].name: missing")).toBe(false);
expect(NULL_TYPE_TOOL_CALL_PATTERN.test("Error: tool call failed")).toBe(false);
});

it("does not match unrelated null errors", () => {
expect(NULL_TYPE_TOOL_CALL_PATTERN.test("Unexpected null value in response")).toBe(false);
expect(NULL_TYPE_TOOL_CALL_PATTERN.test("CAPIError: 400 Bad Request")).toBe(false);
expect(NULL_TYPE_TOOL_CALL_PATTERN.test("")).toBe(false);
});
});

describe("null-type tool_call restarts fresh instead of --continue", () => {
// Inline the same retry logic as the driver including null-type tool_call handling
const MCP_POLICY_BLOCKED_PATTERN = /MCP servers were blocked by policy:/;
const NO_AUTH_INFO_PATTERN = /No authentication information found/;
const NULL_TYPE_TOOL_CALL_PATTERN = /tool_calls\[.*?\]\.type.*null/;
const MAX_RETRIES = 3;

/**
* @param {{hasOutput: boolean, exitCode: number, output: string}} result
* @param {number} attempt
* @param {boolean} useContinueOnRetry
* @param {boolean} continueDisabledPermanently
* @returns {{ shouldRetry: boolean, useContinueOnRetry: boolean, continueDisabledPermanently: boolean }}
*/
function applyRetryPolicy(result, attempt, useContinueOnRetry = false, continueDisabledPermanently = false) {
if (result.exitCode === 0) return { shouldRetry: false, useContinueOnRetry, continueDisabledPermanently };
if (MCP_POLICY_BLOCKED_PATTERN.test(result.output)) return { shouldRetry: false, useContinueOnRetry, continueDisabledPermanently };
if (NO_AUTH_INFO_PATTERN.test(result.output)) {
if (useContinueOnRetry && attempt < MAX_RETRIES) {
return { shouldRetry: true, useContinueOnRetry: false, continueDisabledPermanently: true };
}
return { shouldRetry: false, useContinueOnRetry, continueDisabledPermanently };
}
if (NULL_TYPE_TOOL_CALL_PATTERN.test(result.output)) {
if (attempt < MAX_RETRIES && result.hasOutput) {
return { shouldRetry: true, useContinueOnRetry: false, continueDisabledPermanently: true };
}
return { shouldRetry: false, useContinueOnRetry, continueDisabledPermanently };
}
if (attempt < MAX_RETRIES && result.hasOutput) {
return { shouldRetry: true, useContinueOnRetry: !continueDisabledPermanently, continueDisabledPermanently };
}
return { shouldRetry: false, useContinueOnRetry, continueDisabledPermanently };
}

it("restarts fresh when null-type error occurs on a --continue attempt", () => {
const result = {
exitCode: 1,
hasOutput: true,
output: "CAPIError: 400 Invalid type for 'messages[45].tool_calls[0].type': expected one of 'function', 'all...ols', or 'custom', but got null instead.",
};
const {
shouldRetry,
useContinueOnRetry: newContinue,
continueDisabledPermanently: disabled,
} = applyRetryPolicy(
result,
1,
true, // was using --continue
false
);
expect(shouldRetry).toBe(true);
expect(newContinue).toBe(false); // must NOT use --continue on restart
expect(disabled).toBe(true); // permanently disabled
});

it("restarts fresh when null-type error occurs on a fresh run", () => {
const result = {
exitCode: 1,
hasOutput: true,
output: "CAPIError: 400 Invalid type for 'messages[0].tool_calls[0].type': got null instead.",
};
const { shouldRetry, useContinueOnRetry: newContinue, continueDisabledPermanently: disabled } = applyRetryPolicy(result, 0, false, false);
expect(shouldRetry).toBe(true);
expect(newContinue).toBe(false); // must NOT use --continue
expect(disabled).toBe(true); // permanently disabled
});

it("does not retry when budget is exhausted", () => {
const result = {
exitCode: 1,
hasOutput: true,
output: "tool_calls[0].type: null",
};
const { shouldRetry } = applyRetryPolicy(result, MAX_RETRIES, true, false);
expect(shouldRetry).toBe(false);
});

it("does not retry when no output was produced", () => {
const result = {
exitCode: 1,
hasOutput: false,
output: "tool_calls[0].type: null",
};
const { shouldRetry } = applyRetryPolicy(result, 0, false, false);
expect(shouldRetry).toBe(false);
});
});

describe("permanent --continue disable guard", () => {
// Inline retry logic to verify that once continueDisabledPermanently is set,
// subsequent partial-execution retries never re-enable --continue.
const NULL_TYPE_TOOL_CALL_PATTERN = /tool_calls\[.*?\]\.type.*null/;
const MAX_RETRIES = 3;

/**
* @param {{hasOutput: boolean, exitCode: number, output: string}} result
* @param {number} attempt
* @param {boolean} useContinueOnRetry
* @param {boolean} continueDisabledPermanently
* @returns {{ shouldRetry: boolean, useContinueOnRetry: boolean, continueDisabledPermanently: boolean }}
*/
function applyRetryPolicy(result, attempt, useContinueOnRetry = false, continueDisabledPermanently = false) {
if (result.exitCode === 0) return { shouldRetry: false, useContinueOnRetry, continueDisabledPermanently };
if (NULL_TYPE_TOOL_CALL_PATTERN.test(result.output)) {
if (attempt < MAX_RETRIES && result.hasOutput) {
return { shouldRetry: true, useContinueOnRetry: false, continueDisabledPermanently: true };
}
return { shouldRetry: false, useContinueOnRetry, continueDisabledPermanently };
}
if (attempt < MAX_RETRIES && result.hasOutput) {
return { shouldRetry: true, useContinueOnRetry: !continueDisabledPermanently, continueDisabledPermanently };
}
return { shouldRetry: false, useContinueOnRetry, continueDisabledPermanently };
}

it("does not re-enable --continue after a null-type fresh restart", () => {
// Attempt 0 (fresh): normal failure → schedule --continue
const attempt0Result = { exitCode: 1, hasOutput: true, output: "some error" };
const after0 = applyRetryPolicy(attempt0Result, 0, false, false);
expect(after0.shouldRetry).toBe(true);
expect(after0.useContinueOnRetry).toBe(true);
expect(after0.continueDisabledPermanently).toBe(false);

// Attempt 1 (--continue): null-type error → restart fresh, disable permanently
const attempt1Result = { exitCode: 1, hasOutput: true, output: "tool_calls[0].type: null" };
const after1 = applyRetryPolicy(attempt1Result, 1, after0.useContinueOnRetry, after0.continueDisabledPermanently);
expect(after1.shouldRetry).toBe(true);
expect(after1.useContinueOnRetry).toBe(false); // disabled for this retry
expect(after1.continueDisabledPermanently).toBe(true); // permanently set

// Attempt 2 (fresh): another partial failure → MUST NOT re-enable --continue
const attempt2Result = { exitCode: 1, hasOutput: true, output: "another error" };
const after2 = applyRetryPolicy(attempt2Result, 2, after1.useContinueOnRetry, after1.continueDisabledPermanently);
expect(after2.shouldRetry).toBe(true);
expect(after2.useContinueOnRetry).toBe(false); // guard prevents re-enabling
expect(after2.continueDisabledPermanently).toBe(true);
});

it("does not re-enable --continue after an auth-error fresh restart", () => {
const NO_AUTH_INFO_PATTERN_LOCAL = /No authentication information found/;

function applyRetryPolicyWithAuth(result, attempt, useContinueOnRetry = false, continueDisabledPermanently = false) {
if (result.exitCode === 0) return { shouldRetry: false, useContinueOnRetry, continueDisabledPermanently };
if (NO_AUTH_INFO_PATTERN_LOCAL.test(result.output)) {
if (useContinueOnRetry && attempt < MAX_RETRIES) {
return { shouldRetry: true, useContinueOnRetry: false, continueDisabledPermanently: true };
}
return { shouldRetry: false, useContinueOnRetry, continueDisabledPermanently };
}
if (attempt < MAX_RETRIES && result.hasOutput) {
return { shouldRetry: true, useContinueOnRetry: !continueDisabledPermanently, continueDisabledPermanently };
}
return { shouldRetry: false, useContinueOnRetry, continueDisabledPermanently };
}

// Attempt 0 (fresh): normal failure → schedule --continue
const attempt0Result = { exitCode: 1, hasOutput: true, output: "some work done" };
const after0 = applyRetryPolicyWithAuth(attempt0Result, 0, false, false);
expect(after0.useContinueOnRetry).toBe(true);

// Attempt 1 (--continue): auth error → restart fresh, disable permanently
const attempt1Result = { exitCode: 1, hasOutput: true, output: "No authentication information found" };
const after1 = applyRetryPolicyWithAuth(attempt1Result, 1, after0.useContinueOnRetry, after0.continueDisabledPermanently);
expect(after1.shouldRetry).toBe(true);
expect(after1.useContinueOnRetry).toBe(false);
expect(after1.continueDisabledPermanently).toBe(true);

// Attempt 2 (fresh): partial failure → MUST NOT re-enable --continue
const attempt2Result = { exitCode: 1, hasOutput: true, output: "some other error" };
const after2 = applyRetryPolicyWithAuth(attempt2Result, 2, after1.useContinueOnRetry, after1.continueDisabledPermanently);
expect(after2.useContinueOnRetry).toBe(false); // guard prevents re-enabling
});
});

describe("retry configuration", () => {
it("has sensible default values", () => {
// These match the constants in copilot_harness.cjs
Expand Down