mirror of
https://github.com/paperclipai/paperclip
synced 2026-04-25 17:25:15 +02:00
[codex] Improve transient recovery and Codex model refresh (#4383)
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - Adapter execution and retry classification decide whether agent work pauses, retries, or recovers automatically > - Transient provider failures need to be classified precisely so Paperclip does not convert retryable upstream conditions into false hard failures > - At the same time, operators need an up-to-date model list for Codex-backed agents and prompts should nudge agents toward targeted verification instead of repo-wide sweeps > - This pull request tightens transient recovery classification for Claude and Codex, updates the agent prompt guidance, and adds Codex model refresh support end-to-end > - The benefit is better automatic retry behavior plus fresher operator-facing model configuration ## What Changed - added Codex usage-limit retry-window parsing and Claude extra-usage transient classification - normalized the heartbeat transient-recovery contract across adapter executions and heartbeat scheduling - documented that deferred comment wakes only reopen completed issues for human/comment-reopen interactions, while system follow-ups leave closed work closed - updated adapter-utils prompt guidance to prefer targeted verification - added Codex model refresh support in the server route, registry, shared types, and agent config form - added adapter/server tests covering the new parsing, retry scheduling, and model-refresh behavior ## Verification - `pnpm exec vitest run --project @paperclipai/adapter-utils packages/adapter-utils/src/server-utils.test.ts` - `pnpm exec vitest run --project @paperclipai/adapter-claude-local packages/adapters/claude-local/src/server/parse.test.ts` - `pnpm exec vitest run --project @paperclipai/adapter-codex-local packages/adapters/codex-local/src/server/parse.test.ts` - `pnpm exec vitest run --project @paperclipai/server server/src/__tests__/adapter-model-refresh-routes.test.ts server/src/__tests__/adapter-models.test.ts server/src/__tests__/claude-local-execute.test.ts server/src/__tests__/codex-local-execute.test.ts server/src/__tests__/heartbeat-process-recovery.test.ts server/src/__tests__/heartbeat-retry-scheduling.test.ts` ## Risks - Moderate behavior risk: retry classification affects whether runs auto-recover or block, so mistakes here could either suppress needed retries or over-retry real failures - Low workflow risk: deferred comment wake reopening is intentionally scoped to human/comment-reopen interactions so system follow-ups do not revive completed issues unexpectedly > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex GPT-5-based coding agent with tool use and code execution in the Codex CLI environment ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [ ] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -164,9 +164,9 @@ export const agentsApi = {
|
||||
api.get<AgentTaskSession[]>(agentPath(id, companyId, "/task-sessions")),
|
||||
resetSession: (id: string, taskKey?: string | null, companyId?: string) =>
|
||||
api.post<void>(agentPath(id, companyId, "/runtime-state/reset-session"), { taskKey: taskKey ?? null }),
|
||||
adapterModels: (companyId: string, type: string) =>
|
||||
adapterModels: (companyId: string, type: string, options?: { refresh?: boolean }) =>
|
||||
api.get<AdapterModel[]>(
|
||||
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/models`,
|
||||
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/models${options?.refresh ? "?refresh=1" : ""}`,
|
||||
),
|
||||
detectModel: (companyId: string, type: string) =>
|
||||
api.get<DetectedAdapterModel | null>(
|
||||
|
||||
@@ -302,16 +302,19 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
);
|
||||
|
||||
// Fetch adapter models for the effective adapter type
|
||||
const modelQueryKey = selectedCompanyId
|
||||
? queryKeys.agents.adapterModels(selectedCompanyId, adapterType)
|
||||
: ["agents", "none", "adapter-models", adapterType];
|
||||
const {
|
||||
data: fetchedModels,
|
||||
error: fetchedModelsError,
|
||||
} = useQuery({
|
||||
queryKey: selectedCompanyId
|
||||
? queryKeys.agents.adapterModels(selectedCompanyId, adapterType)
|
||||
: ["agents", "none", "adapter-models", adapterType],
|
||||
queryKey: modelQueryKey,
|
||||
queryFn: () => agentsApi.adapterModels(selectedCompanyId!, adapterType),
|
||||
enabled: Boolean(selectedCompanyId),
|
||||
});
|
||||
const [refreshModelsError, setRefreshModelsError] = useState<string | null>(null);
|
||||
const [refreshingModels, setRefreshingModels] = useState(false);
|
||||
const models = fetchedModels ?? externalModels ?? [];
|
||||
const adapterCommandField =
|
||||
adapterType === "hermes_local" ? "hermesCommand" : "command";
|
||||
@@ -401,6 +404,20 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
? val!.model
|
||||
: eff("adapterConfig", "model", String(config.model ?? ""));
|
||||
|
||||
async function handleRefreshModels() {
|
||||
if (!selectedCompanyId) return;
|
||||
setRefreshingModels(true);
|
||||
setRefreshModelsError(null);
|
||||
try {
|
||||
const refreshed = await agentsApi.adapterModels(selectedCompanyId, adapterType, { refresh: true });
|
||||
queryClient.setQueryData(modelQueryKey, refreshed);
|
||||
} catch (error) {
|
||||
setRefreshModelsError(error instanceof Error ? error.message : "Failed to refresh adapter models.");
|
||||
} finally {
|
||||
setRefreshingModels(false);
|
||||
}
|
||||
}
|
||||
|
||||
const thinkingEffortKey =
|
||||
adapterType === "codex_local"
|
||||
? "modelReasoningEffort"
|
||||
@@ -792,14 +809,17 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
const result = await refetchDetectedModel();
|
||||
return result.data?.model ?? null;
|
||||
}}
|
||||
onRefreshModels={adapterType === "codex_local" ? handleRefreshModels : undefined}
|
||||
refreshingModels={refreshingModels}
|
||||
detectModelLabel="Detect model"
|
||||
emptyDetectHint="No model detected. Select or enter one manually."
|
||||
/>
|
||||
{fetchedModelsError && (
|
||||
{(refreshModelsError || fetchedModelsError) && (
|
||||
<p className="text-xs text-destructive">
|
||||
{fetchedModelsError instanceof Error
|
||||
? fetchedModelsError.message
|
||||
: "Failed to load adapter models."}
|
||||
{refreshModelsError
|
||||
?? (fetchedModelsError instanceof Error
|
||||
? fetchedModelsError.message
|
||||
: "Failed to load adapter models.")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -1134,6 +1154,8 @@ function ModelDropdown({
|
||||
detectedModel,
|
||||
detectedModelCandidates,
|
||||
onDetectModel,
|
||||
onRefreshModels,
|
||||
refreshingModels,
|
||||
detectModelLabel,
|
||||
emptyDetectHint,
|
||||
}: {
|
||||
@@ -1149,6 +1171,8 @@ function ModelDropdown({
|
||||
detectedModel?: string | null;
|
||||
detectedModelCandidates?: string[];
|
||||
onDetectModel?: () => Promise<string | null>;
|
||||
onRefreshModels?: () => Promise<void>;
|
||||
refreshingModels?: boolean;
|
||||
detectModelLabel?: string;
|
||||
emptyDetectHint?: string;
|
||||
}) {
|
||||
@@ -1280,6 +1304,24 @@ function ModelDropdown({
|
||||
{detectingModel ? "Detecting..." : detectedModel ? (detectModelLabel?.replace(/^Detect\b/, "Re-detect") ?? "Re-detect from config") : (detectModelLabel ?? "Detect from config")}
|
||||
</button>
|
||||
)}
|
||||
{onRefreshModels && !modelSearch.trim() && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-muted-foreground"
|
||||
onClick={() => {
|
||||
void onRefreshModels();
|
||||
}}
|
||||
disabled={refreshingModels}
|
||||
>
|
||||
<svg aria-hidden="true" focusable="false" className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 12a9 9 0 0 1 15.28-6.36L21 8" />
|
||||
<path d="M21 3v5h-5" />
|
||||
<path d="M21 12a9 9 0 0 1-15.28 6.36L3 16" />
|
||||
<path d="M8 16H3v5" />
|
||||
</svg>
|
||||
{refreshingModels ? "Refreshing..." : "Refresh models"}
|
||||
</button>
|
||||
)}
|
||||
{value && (!models.some((m) => m.id === value) || promotedModelIds.has(value)) && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
Reference in New Issue
Block a user