Skip to content

Commit a7f2979

Browse files
committed
Normalize codex models and workflow diagnostics
1 parent c83dec7 commit a7f2979

32 files changed

Lines changed: 821 additions & 78 deletions

packages/cli/src/features/config/project-resolution.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { normalizeCodexModel } from "adapters/codex";
12
import type {
23
DeepPartial,
34
DevosRootConfig,
@@ -119,6 +120,9 @@ function mergeRuntime(
119120
...base.codex,
120121
...(rootDefaults.codex ?? {}),
121122
...(project.codex ?? {}),
123+
model: normalizeCodexModel(
124+
project.codex?.model ?? rootDefaults.codex?.model ?? base.codex.model,
125+
),
122126
docker: {
123127
...(base.codex.docker ?? {}),
124128
...(rootDefaults.codex?.docker ?? {}),
@@ -130,9 +134,9 @@ function mergeRuntime(
130134
...(project.codex?.reasoningEfforts ?? {}),
131135
},
132136
models: {
133-
...(base.codex.models ?? {}),
134-
...(rootDefaults.codex?.models ?? {}),
135-
...(project.codex?.models ?? {}),
137+
...normalizeCodexModels(base.codex.models),
138+
...normalizeCodexModels(rootDefaults.codex?.models),
139+
...normalizeCodexModels(project.codex?.models),
136140
},
137141
fastModes: {
138142
...(base.codex.fastModes ?? {}),
@@ -171,3 +175,15 @@ function mergeRuntime(
171175
dryRun: project.dryRun ?? rootDefaults.dryRun ?? base.dryRun,
172176
};
173177
}
178+
179+
function normalizeCodexModels(
180+
models: ProjectRuntimeConfig["codex"]["models"] | undefined,
181+
): ProjectRuntimeConfig["codex"]["models"] {
182+
if (!models) return {};
183+
return Object.fromEntries(
184+
Object.entries(models).map(([key, value]) => [
185+
key,
186+
normalizeCodexModel(value),
187+
]),
188+
) as ProjectRuntimeConfig["codex"]["models"];
189+
}

packages/cli/src/features/models/model-command.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { normalizeCodexModel } from "adapters/codex";
12
import { instanceConfigPath } from "../config";
23
import { loadInstanceConfig, saveInstanceConfig } from "../onboard";
34
import type {
@@ -35,6 +36,7 @@ export async function handleModelsCommand(
3536
}
3637

3738
resetModelSettings(config, command.stage);
39+
normalizeExistingModels(config);
3840
await (deps.saveInstanceConfig ?? saveInstanceConfig)(config);
3941
write(`Reset ${stageLabel(command.stage)} model settings.\n`);
4042
}
@@ -57,7 +59,7 @@ function applyModelSettings(
5759
if (command.model !== undefined) {
5860
config.codex.models = {
5961
...(config.codex.models ?? {}),
60-
[command.stage]: command.model,
62+
[command.stage]: normalizeCodexModel(command.model),
6163
};
6264
}
6365
if (command.reasoningEffort !== undefined) {
@@ -68,6 +70,17 @@ function applyModelSettings(
6870
}
6971
}
7072

73+
function normalizeExistingModels(
74+
config: Awaited<ReturnType<typeof readInstanceConfig>>,
75+
): void {
76+
if (!config.codex?.models) return;
77+
for (const stage of STAGES) {
78+
config.codex.models[stage] = normalizeCodexModel(
79+
config.codex.models[stage],
80+
);
81+
}
82+
}
83+
7184
function resetModelSettings(
7285
config: Awaited<ReturnType<typeof readInstanceConfig>>,
7386
stage: ModelStage,
@@ -98,7 +111,7 @@ function renderModelsList(
98111
lines.push(
99112
[
100113
stageLabel(stage),
101-
config.codex?.models?.[stage] ?? "-",
114+
normalizeCodexModel(config.codex?.models?.[stage]) ?? "-",
102115
config.codex?.reasoningEfforts?.[stage] ?? "-",
103116
].join("\t"),
104117
);

packages/cli/src/features/onboard/onboard-draft.ts

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import path from "node:path";
2+
import { CODEX_DEFAULT_STAGE_MODELS } from "adapters/codex";
23
import { clackPromptAdapter } from "../prompts";
34
import type { PromptAdapter } from "../prompts";
45
import {
@@ -14,13 +15,6 @@ import type {
1415
} from "./types/onboard.types";
1516
import { resolveUserPath } from "./wizard-helpers";
1617

17-
const DEFAULT_CODEX_MODELS = {
18-
brainstorm: "gpt-5.5",
19-
plan: "gpt-5.5",
20-
implement: "gpt-5.3-codex",
21-
reviewTest: "gpt-5.3-codex",
22-
} as const;
23-
2418
const ISOLATED_WORKTREES_DESCRIPTION =
2519
"Keeps each workflow task in its own git worktree so agent changes do not collide with your main checkout or other running tasks.";
2620

@@ -62,11 +56,11 @@ export async function collectOnboardDraft(
6256
githubComment: DEFAULT_REASONING_EFFORTS.reviewTest,
6357
},
6458
models: {
65-
brainstorm: DEFAULT_CODEX_MODELS.brainstorm,
66-
plan: DEFAULT_CODEX_MODELS.plan,
67-
implement: DEFAULT_CODEX_MODELS.implement,
68-
reviewTest: DEFAULT_CODEX_MODELS.reviewTest,
69-
githubComment: DEFAULT_CODEX_MODELS.reviewTest,
59+
brainstorm: CODEX_DEFAULT_STAGE_MODELS.brainstorm,
60+
plan: CODEX_DEFAULT_STAGE_MODELS.plan,
61+
implement: CODEX_DEFAULT_STAGE_MODELS.implement,
62+
reviewTest: CODEX_DEFAULT_STAGE_MODELS.reviewTest,
63+
githubComment: CODEX_DEFAULT_STAGE_MODELS.githubComment,
7064
},
7165
plugins: ["github@openai-curated"],
7266
skillsets: ["devos"],

packages/cli/src/features/workflow/implementation/implement-stage.ts

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ import type {
1818
} from "../types/workflow.types";
1919
import { appendCodexUsage } from "../usage/usage-state";
2020

21+
const NO_STAGED_CHANGES_ERROR =
22+
"No staged changes found after implement step; cannot create PR";
23+
2124
export function fixedBugsForImplementationComment(
2225
hasExistingPr: boolean,
2326
bugs: RunState["bugs"],
@@ -100,12 +103,22 @@ export async function handleImplementingStage(
100103
url: "https://example.invalid/dry-run",
101104
};
102105
} else {
103-
state.pullRequest = await runtime.createDraftPrFromWorktree(
104-
config,
105-
state.issue.key,
106-
state.issue.title,
107-
state.issue.branchName,
108-
);
106+
state.pullRequest = await runtime
107+
.createDraftPrFromWorktree(
108+
config,
109+
state.issue.key,
110+
state.issue.title,
111+
state.issue.branchName,
112+
)
113+
.catch((error) => {
114+
if (!isNoStagedChangesError(error)) {
115+
throw error;
116+
}
117+
throw noImplementationChangesError(
118+
state.implementationSummary,
119+
error,
120+
);
121+
});
109122
}
110123
} else if (!config.dryRun) {
111124
if (!state.pullRequest?.branch) {
@@ -148,6 +161,27 @@ export async function handleImplementingStage(
148161
);
149162
}
150163

164+
function isNoStagedChangesError(error: unknown): boolean {
165+
return (
166+
error instanceof Error && error.message.includes(NO_STAGED_CHANGES_ERROR)
167+
);
168+
}
169+
170+
function noImplementationChangesError(
171+
implementationSummary: string | undefined,
172+
cause: unknown,
173+
): Error {
174+
const summary = implementationSummary?.trim() || "No agent output recorded.";
175+
const error = new Error(
176+
[
177+
"Implementation completed without file changes; no draft PR was created.",
178+
`Agent output: ${summary}`,
179+
].join("\n\n"),
180+
);
181+
Object.assign(error, { cause });
182+
return error;
183+
}
184+
151185
export async function prepareImplementationBranchForStage(
152186
config: ResolvedProjectConfig,
153187
state: RunState,

packages/cli/src/features/workflow/pipeline/issue-pipeline-executor.ts

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@ import type {
66
} from "../../types";
77
import { ReviewMergeDecisionManager } from "../review/review-orchestrator";
88
import { saveRunState, transitionStage } from "../state";
9+
import type { PipelineRunResult } from "../types/workflow-metadata.types";
910
import type {
1011
WorkflowRuntime,
1112
WorkflowTaskClient,
1213
} from "../types/workflow.types";
14+
import { formatWorkflowError } from "../utils/error-format";
1315
import { heartbeatRunLease } from "../workflow-lease";
1416
import { isReviewOnlyExecutableStage } from "../workflow-queue";
1517
import { createBuiltInWorkflowMetadata } from "./built-in-workflow-metadata";
@@ -83,14 +85,7 @@ export class IssuePipelineExecutor {
8385
},
8486
});
8587
if (!pipelineResult.ok) {
86-
const failed = pipelineResult.phaseResults.find(
87-
(result) => result.status === "rejected",
88-
);
89-
throw new Error(
90-
failed?.status === "rejected"
91-
? failed.error
92-
: "Workflow pipeline failed",
93-
);
88+
throw resolvePipelineFailureError(pipelineResult);
9489
}
9590
}
9691

@@ -127,3 +122,20 @@ export class IssuePipelineExecutor {
127122
await saveRunState(this.config.workspacePath, state);
128123
}
129124
}
125+
126+
export function resolvePipelineFailureError(result: PipelineRunResult): Error {
127+
const failed = result.phaseResults.find(
128+
(phaseResult) => phaseResult.status === "rejected",
129+
);
130+
if (failed?.status !== "rejected") {
131+
return new Error("Workflow pipeline failed");
132+
}
133+
return normalizePipelineError(failed.error);
134+
}
135+
136+
function normalizePipelineError(error: unknown): Error {
137+
if (error instanceof Error) {
138+
return error;
139+
}
140+
return new Error(formatWorkflowError(error));
141+
}

packages/cli/src/features/workflow/pipeline/phase-runner.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import type {
66
WorkflowMetadata,
77
WorkflowPhaseDefinition,
88
} from "../types/workflow-metadata.types";
9-
import { formatWorkflowError } from "../utils/error-format";
109

1110
export interface PhaseRunnerDeps {
1211
runAgent(input: PhaseAgentRunInput): Promise<PhaseAgentRunResult>;
@@ -38,7 +37,7 @@ export class PhaseRunner {
3837
return {
3938
status: "rejected",
4039
phase,
41-
error: formatWorkflowError(result.reason),
40+
error: result.reason,
4241
};
4342
}
4443
}

packages/cli/src/features/workflow/types/workflow-metadata.types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ export type PhaseRunResult =
5959
| {
6060
status: "rejected";
6161
phase: WorkflowPhaseDefinition;
62-
error: string;
62+
error: unknown;
6363
}
6464
| {
6565
status: "skipped";

packages/cli/src/integrations/github/github.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type {
99
PullRequestRef,
1010
ResolvedProjectConfig,
1111
} from "../../features/types";
12+
import { logger } from "../../utils/logger";
1213
import {
1314
PREP_COMMAND_TIMEOUT_MS,
1415
runPrepCommandWithRetry,
@@ -22,6 +23,7 @@ import type {
2223
} from "./types/github.types";
2324

2425
const GITHUB_RETRY_ATTEMPTS = 3;
26+
const GIT_NOT_ANCESTOR_EXIT_CODE = 1;
2527

2628
export function issueBranchName(issueKey: string, branchName?: string): string {
2729
const trimmedBranchName = branchName?.trim();
@@ -105,13 +107,22 @@ export async function ensureBaseBranchFresh(
105107
return;
106108
}
107109

108-
const ancestor = await runPrepCommandWithRetry(
109-
"git verify base branch ancestor",
110+
const ancestor = await commandRunner(
110111
"git",
111112
["merge-base", "--is-ancestor", baseBranch, `origin/${baseBranch}`],
112-
{ cwd: config.executionPath },
113-
commandRunner,
113+
{ cwd: config.executionPath, timeoutMs: PREP_COMMAND_TIMEOUT_MS },
114114
);
115+
if (ancestor.code === GIT_NOT_ANCESTOR_EXIT_CODE) {
116+
logger.warn(
117+
{
118+
baseBranch,
119+
cwd: config.executionPath,
120+
remoteBranch: `origin/${baseBranch}`,
121+
},
122+
"Skipping local base branch ref update because it has commits not in the remote base",
123+
);
124+
return;
125+
}
115126
assertOk(
116127
"git",
117128
["merge-base", "--is-ancestor", baseBranch, `origin/${baseBranch}`],

packages/cli/src/utils/logger.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ export function normalizeError(input: unknown): Record<string, unknown> {
8585
name: input.name,
8686
message: input.message,
8787
stack: input.stack,
88+
...normalizeDiagnosticErrorFields(input),
8889
};
8990
}
9091
return { message: String(input) };
@@ -187,3 +188,24 @@ function indentBlock(value: string): string {
187188
function isLogContext(value: unknown): value is CliLogContext {
188189
return typeof value === "object" && value !== null;
189190
}
191+
192+
function normalizeDiagnosticErrorFields(error: Error): Record<string, unknown> {
193+
const diagnostic = error as Error & Record<string, unknown>;
194+
return pickDefined({
195+
backend: diagnostic.backend,
196+
command: diagnostic.command,
197+
cwd: diagnostic.cwd,
198+
code: diagnostic.code,
199+
stdout: diagnostic.stdout,
200+
stderr: diagnostic.stderr,
201+
traceId: diagnostic.traceId,
202+
});
203+
}
204+
205+
function pickDefined(
206+
input: Record<string, unknown | undefined>,
207+
): Record<string, unknown> {
208+
return Object.fromEntries(
209+
Object.entries(input).filter(([, value]) => value !== undefined),
210+
);
211+
}

packages/cli/tests/codex.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,10 +155,14 @@ describe("codex adapter", () => {
155155
expect(calls).toHaveLength(3);
156156
expect(calls[0]).toContain('service_tier="fast"');
157157
expect(calls[0]).toContain("features.fast_mode=true");
158+
expect(calls[0]).toContain("--ignore-user-config");
158159
expect(calls[1]).not.toContain('service_tier="fast"');
160+
expect(calls[1]).not.toContain('service_tier="flex"');
159161
expect(calls[1]).not.toContain("features.fast_mode=true");
162+
expect(calls[1]).toContain("--ignore-user-config");
160163
expect(calls[2]).toContain('service_tier="fast"');
161164
expect(calls[2]).toContain("features.fast_mode=true");
165+
expect(calls[2]).toContain("--ignore-user-config");
162166
});
163167

164168
it("uses github-comment model override when present", async () => {

0 commit comments

Comments
 (0)