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
17 changes: 14 additions & 3 deletions src/backends/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -558,10 +558,21 @@ export async function executeWithEngine(
};

monitor?.start();
let result: Awaited<ReturnType<typeof engine.execute>>;
let result: Awaited<ReturnType<typeof engine.execute>> | undefined;
try {
result = await engine.execute(executionPlan);
await hydrateNativeToolSidecars(result, prSidecarPath, reviewSidecarPath);
if (engine.beforeExecute) {
await engine.beforeExecute(executionPlan);
}
try {
result = await engine.execute(executionPlan);
} finally {
if (engine.afterExecute) {
// afterExecute always runs; pass result if available (execute() may have thrown).
await engine.afterExecute(executionPlan, result ?? { success: false, output: '' });
}
}
// biome-ignore lint/style/noNonNullAssertion: result is always defined when execute() did not throw
await hydrateNativeToolSidecars(result!, prSidecarPath, reviewSidecarPath);
const completionEvidence = readCompletionEvidence(executionPlan.completionRequirements);

postProcessResult(result, agentType, engine, input, identifier, {
Expand Down
157 changes: 79 additions & 78 deletions src/backends/claude-code/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,20 @@ export class ClaudeCodeEngine implements AgentEngine {
return resolveClaudeModel(cascadeModel);
}

async beforeExecute(plan: AgentExecutionPlan): Promise<void> {
// Ensure onboarding flag exists (required for both API key and subscription auth)
ensureOnboardingFlag();
// Log repo directory state for debugging
debugRepoDirectory(plan.repoDir);
}

async afterExecute(plan: AgentExecutionPlan, _result: AgentEngineResult): Promise<void> {
// Clean up offloaded context files after execution
await cleanupContextFiles(plan.repoDir);
// Clean up persisted session directory — workers are ephemeral
await cleanupPersistedSession(plan.repoDir);
}

async execute(input: AgentExecutionPlan): Promise<AgentEngineResult> {
const startTime = Date.now();
const systemPrompt = buildSystemPrompt(input.systemPrompt, input.availableTools);
Expand Down Expand Up @@ -488,100 +502,87 @@ export class ClaudeCodeEngine implements AgentEngine {
input.cliToolsDir,
input.nativeToolShimDir,
);
// Always ensure onboarding flag exists (required for both API key and subscription auth)
ensureOnboardingFlag();
const hooks = buildHooks(input.logWriter, input.repoDir, input.enableStopHooks ?? true, {
blockGitPush: input.blockGitPush,
});

const sdkTools = resolveNativeTools(input.nativeToolCapabilities);

debugRepoDirectory(input.repoDir);

const maxContinuationTurns = input.completionRequirements?.maxContinuationTurns ?? 0;
let continuationTurns = 0;
let promptText = taskPrompt;
let isContinuation = false;
let turnCount = 0;
let totalCost: number | undefined;

try {
for (;;) {
const stderrChunks: string[] = [];
const stream = query({
prompt: promptText,
options: {
model,
systemPrompt,
cwd: input.repoDir,
additionalDirectories: [getWorkspaceDir()],
maxBudgetUsd: input.budgetUsd,
permissionMode: 'bypassPermissions',
allowDangerouslySkipPermissions: true,
tools: sdkTools,
allowedTools: sdkTools,
persistSession: true,
hooks,
env,
debug: true,
stderr: (data: string) => {
stderrChunks.push(data);
input.logWriter('INFO', 'Claude Code stderr', { data: data.trim() });
},
...(isContinuation ? { continue: true } : {}),
for (;;) {
const stderrChunks: string[] = [];
const stream = query({
prompt: promptText,
options: {
model,
systemPrompt,
cwd: input.repoDir,
additionalDirectories: [getWorkspaceDir()],
maxBudgetUsd: input.budgetUsd,
permissionMode: 'bypassPermissions',
allowDangerouslySkipPermissions: true,
tools: sdkTools,
allowedTools: sdkTools,
persistSession: true,
hooks,
env,
debug: true,
stderr: (data: string) => {
stderrChunks.push(data);
input.logWriter('INFO', 'Claude Code stderr', { data: data.trim() });
},
});

const {
assistantMessages,
resultMessage,
turnCount: newTurnCount,
toolCallCount,
} = await consumeStream(stream, input, model, turnCount);
turnCount = newTurnCount;

const turnResult = buildResult(
assistantMessages,
resultMessage,
stderrChunks,
input,
startTime,
);

// Accumulate cost across continuation turns
if (turnResult.cost !== undefined) {
totalCost = (totalCost ?? 0) + turnResult.cost;
}

const result = applyCompletionEvidence(turnResult, input.completionRequirements);

// Don't continue on non-success results
if (!result.success) {
return { ...result, cost: totalCost };
}

const decision = decideContinuation(
result,
input.completionRequirements,
continuationTurns,
maxContinuationTurns,
totalCost,
input.logWriter,
toolCallCount,
);
if (decision.done) return decision.result;

continuationTurns++;
promptText = decision.promptText;
isContinuation = true;
...(isContinuation ? { continue: true } : {}),
},
});

const {
assistantMessages,
resultMessage,
turnCount: newTurnCount,
toolCallCount,
} = await consumeStream(stream, input, model, turnCount);
turnCount = newTurnCount;

const turnResult = buildResult(
assistantMessages,
resultMessage,
stderrChunks,
input,
startTime,
);

// Accumulate cost across continuation turns
if (turnResult.cost !== undefined) {
totalCost = (totalCost ?? 0) + turnResult.cost;
}
} finally {
// Clean up offloaded context files after execution
if (hasOffloadedContext) {
await cleanupContextFiles(input.repoDir);

const result = applyCompletionEvidence(turnResult, input.completionRequirements);

// Don't continue on non-success results
if (!result.success) {
return { ...result, cost: totalCost };
}
// Clean up persisted session directory — workers are ephemeral
await cleanupPersistedSession(input.repoDir);

const decision = decideContinuation(
result,
input.completionRequirements,
continuationTurns,
maxContinuationTurns,
totalCost,
input.logWriter,
toolCallCount,
);
if (decision.done) return decision.result;

continuationTurns++;
promptText = decision.promptText;
isContinuation = true;
}
}
}
71 changes: 60 additions & 11 deletions src/backends/codex/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,11 @@ async function captureRefreshedToken(
export class CodexEngine implements AgentEngine {
readonly definition = CODEX_ENGINE_DEFINITION;

/** Stores the original auth JSON so afterExecute can detect token refreshes. */
private _originalAuthJson: string | undefined;
/** True when beforeExecute has been called (adapter lifecycle is active). */
private _adapterLifecycleActive = false;

supportsAgentType(_agentType: string): boolean {
return true;
}
Expand All @@ -484,6 +489,45 @@ export class CodexEngine implements AgentEngine {
return resolveCodexModel(cascadeModel);
}

async beforeExecute(plan: AgentExecutionPlan): Promise<void> {
this._adapterLifecycleActive = true;
this._originalAuthJson = await writeCodexAuthFile(plan.projectSecrets, plan.logWriter);
}

async afterExecute(plan: AgentExecutionPlan, _result: AgentEngineResult): Promise<void> {
await captureRefreshedToken(plan.project.orgId, this._originalAuthJson, plan.logWriter);
await cleanupContextFiles(plan.repoDir);
this._originalAuthJson = undefined;
this._adapterLifecycleActive = false;
}

/** Remove temp file created by execute() — best-effort, ignores errors. */
private static _cleanupLastMessagePath(path: string): void {
if (existsSync(path)) {
try {
unlinkSync(path);
} catch {
// Best-effort cleanup
}
}
}

/** Cleanup called from execute() finally block when adapter lifecycle is not active. */
private async _directCallCleanup(
repoDir: string,
orgId: string | undefined,
originalAuthJson: string | undefined,
logWriter: AgentExecutionPlan['logWriter'],
hasOffloadedContext: boolean,
): Promise<void> {
if (hasOffloadedContext) {
await cleanupContextFiles(repoDir);
}
if (orgId) {
await captureRefreshedToken(orgId, originalAuthJson, logWriter);
}
}

async execute(input: AgentExecutionPlan): Promise<AgentEngineResult> {
const startTime = Date.now();
const systemPrompt = buildSystemPrompt(input.systemPrompt, input.availableTools);
Expand All @@ -501,7 +545,11 @@ export class CodexEngine implements AgentEngine {
const settings = resolveCodexSettings(input.project, input.nativeToolCapabilities);
assertHeadlessCodexSettings(settings);

const originalAuthJson = await writeCodexAuthFile(input.projectSecrets, input.logWriter);
// When called via adapter, beforeExecute already wrote the auth file.
// When called directly (e.g. tests), write it here for backward compatibility.
const originalAuthJson = this._adapterLifecycleActive
? this._originalAuthJson
: await writeCodexAuthFile(input.projectSecrets, input.logWriter);

// Strip CODEX_AUTH_JSON from env — it's written to disk, not passed to the subprocess
const strippedSecrets: Record<string, string> | undefined = input.projectSecrets
Expand Down Expand Up @@ -656,17 +704,18 @@ export class CodexEngine implements AgentEngine {
prEvidence,
};
} finally {
if (existsSync(lastMessagePath)) {
try {
unlinkSync(lastMessagePath);
} catch {
// Best-effort cleanup
}
}
if (hasOffloadedContext) {
await cleanupContextFiles(input.repoDir);
CodexEngine._cleanupLastMessagePath(lastMessagePath);
// When called directly (not via adapter), afterExecute won't be invoked.
// Perform cleanup here so direct callers (e.g. tests) still behave correctly.
if (!this._adapterLifecycleActive) {
await this._directCallCleanup(
input.repoDir,
input.project.orgId,
originalAuthJson,
input.logWriter,
hasOffloadedContext,
);
}
await captureRefreshedToken(input.project.orgId, originalAuthJson, input.logWriter);
}
}
}
Expand Down
7 changes: 7 additions & 0 deletions src/backends/opencode/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -805,6 +805,13 @@ export class OpenCodeEngine implements AgentEngine {
return resolveOpenCodeModel(cascadeModel);
}

async afterExecute(plan: AgentExecutionPlan, _result: AgentEngineResult): Promise<void> {
// Clean up offloaded context files — idempotent, safe to call from adapter hook.
// Server process and session cleanup happen inside execute()'s finally block
// since those resources are local to the execution.
await cleanupContextFiles(plan.repoDir);
}

async execute(input: AgentExecutionPlan): Promise<AgentEngineResult> {
const settings = resolveOpenCodeSettings(input.project);
const agent = 'build' as const;
Expand Down
12 changes: 12 additions & 0 deletions src/backends/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,4 +153,16 @@ export interface AgentEngine {
* Engines that pass the model through unchanged (e.g., LLMist) do not need to implement it.
*/
resolveModel?(cascadeModel: string): string;
/**
* Optional hook called by the adapter before engine.execute().
* Use for engine-specific environment setup (e.g., writing auth files, checking directories).
* LLMist does not implement this hook.
*/
beforeExecute?(plan: AgentExecutionPlan): Promise<void>;
/**
* Optional hook called by the adapter after engine.execute(), in a finally block.
* Use for engine-specific cleanup (e.g., removing temp files, killing subprocesses).
* LLMist does not implement this hook.
*/
afterExecute?(plan: AgentExecutionPlan, result: AgentEngineResult): Promise<void>;
}
Loading
Loading