mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 10:50:38 +09:00
Merge pull request #1654 from paperclipai/pr/pap-795-agent-runtime
fix(runtime): improve agent recovery and heartbeat operations
This commit is contained in:
commit
f2637e6972
28 changed files with 1291 additions and 64 deletions
|
|
@ -11,7 +11,7 @@ import { LocalWorkspaceRuntimeFields } from "../local-workspace-runtime-fields";
|
|||
const inputClass =
|
||||
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
|
||||
const instructionsFileHint =
|
||||
"Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the system prompt at runtime.";
|
||||
"Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the system prompt at runtime. Note: Codex may still auto-apply repo-scoped AGENTS.md files from the workspace.";
|
||||
|
||||
export function CodexLocalConfigFields({
|
||||
mode,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ export const issuesApi = {
|
|||
status?: string;
|
||||
projectId?: string;
|
||||
assigneeAgentId?: string;
|
||||
participantAgentId?: string;
|
||||
assigneeUserId?: string;
|
||||
touchedByUserId?: string;
|
||||
unreadForUserId?: string;
|
||||
|
|
@ -32,6 +33,7 @@ export const issuesApi = {
|
|||
if (filters?.status) params.set("status", filters.status);
|
||||
if (filters?.projectId) params.set("projectId", filters.projectId);
|
||||
if (filters?.assigneeAgentId) params.set("assigneeAgentId", filters.assigneeAgentId);
|
||||
if (filters?.participantAgentId) params.set("participantAgentId", filters.participantAgentId);
|
||||
if (filters?.assigneeUserId) params.set("assigneeUserId", filters.assigneeUserId);
|
||||
if (filters?.touchedByUserId) params.set("touchedByUserId", filters.touchedByUserId);
|
||||
if (filters?.unreadForUserId) params.set("unreadForUserId", filters.unreadForUserId);
|
||||
|
|
|
|||
|
|
@ -54,6 +54,17 @@ function issueModeForExistingWorkspace(mode: string | null | undefined) {
|
|||
return "shared_workspace";
|
||||
}
|
||||
|
||||
function shouldPresentExistingWorkspaceSelection(issue: Issue) {
|
||||
const persistedMode =
|
||||
issue.currentExecutionWorkspace?.mode
|
||||
?? issue.executionWorkspaceSettings?.mode
|
||||
?? issue.executionWorkspacePreference;
|
||||
return Boolean(
|
||||
issue.executionWorkspaceId &&
|
||||
(persistedMode === "isolated_workspace" || persistedMode === "operator_branch"),
|
||||
);
|
||||
}
|
||||
|
||||
interface IssuePropertiesProps {
|
||||
issue: Issue;
|
||||
onUpdate: (data: Record<string, unknown>) => void;
|
||||
|
|
@ -269,10 +280,6 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
|
|||
? currentProject?.executionWorkspacePolicy ?? null
|
||||
: null;
|
||||
const currentProjectSupportsExecutionWorkspace = Boolean(currentProjectExecutionWorkspacePolicy?.enabled);
|
||||
const currentExecutionWorkspaceSelection =
|
||||
issue.executionWorkspacePreference
|
||||
?? issue.executionWorkspaceSettings?.mode
|
||||
?? defaultExecutionWorkspaceModeForProject(currentProject);
|
||||
const { data: reusableExecutionWorkspaces } = useQuery({
|
||||
queryKey: queryKeys.executionWorkspaces.list(companyId!, {
|
||||
projectId: issue.projectId ?? undefined,
|
||||
|
|
@ -299,9 +306,17 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
|
|||
}
|
||||
return Array.from(seen.values());
|
||||
}, [reusableExecutionWorkspaces]);
|
||||
const selectedReusableExecutionWorkspace = deduplicatedReusableWorkspaces.find(
|
||||
(workspace) => workspace.id === issue.executionWorkspaceId,
|
||||
);
|
||||
const selectedReusableExecutionWorkspace =
|
||||
deduplicatedReusableWorkspaces.find((workspace) => workspace.id === issue.executionWorkspaceId)
|
||||
?? issue.currentExecutionWorkspace
|
||||
?? null;
|
||||
const currentExecutionWorkspaceSelection = shouldPresentExistingWorkspaceSelection(issue)
|
||||
? "reuse_existing"
|
||||
: (
|
||||
issue.executionWorkspacePreference
|
||||
?? issue.executionWorkspaceSettings?.mode
|
||||
?? defaultExecutionWorkspaceModeForProject(currentProject)
|
||||
);
|
||||
const projectLink = (id: string | null) => {
|
||||
if (!id) return null;
|
||||
const project = projects?.find((p) => p.id === id) ?? null;
|
||||
|
|
@ -681,7 +696,9 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
|
|||
>
|
||||
{EXECUTION_WORKSPACE_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
{option.value === "reuse_existing" && selectedReusableExecutionWorkspace?.mode === "isolated_workspace"
|
||||
? "Existing isolated workspace"
|
||||
: option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
|
|
|||
|
|
@ -167,6 +167,9 @@ interface IssuesListProps {
|
|||
issueLinkState?: unknown;
|
||||
initialAssignees?: string[];
|
||||
initialSearch?: string;
|
||||
searchFilters?: {
|
||||
participantAgentId?: string;
|
||||
};
|
||||
onSearchChange?: (search: string) => void;
|
||||
onUpdateIssue: (id: string, data: Record<string, unknown>) => void;
|
||||
}
|
||||
|
|
@ -183,6 +186,7 @@ export function IssuesList({
|
|||
issueLinkState,
|
||||
initialAssignees,
|
||||
initialSearch,
|
||||
searchFilters,
|
||||
onSearchChange,
|
||||
onUpdateIssue,
|
||||
}: IssuesListProps) {
|
||||
|
|
@ -240,8 +244,11 @@ export function IssuesList({
|
|||
}, [scopedKey]);
|
||||
|
||||
const { data: searchedIssues = [] } = useQuery({
|
||||
queryKey: queryKeys.issues.search(selectedCompanyId!, normalizedIssueSearch, projectId),
|
||||
queryFn: () => issuesApi.list(selectedCompanyId!, { q: normalizedIssueSearch, projectId }),
|
||||
queryKey: [
|
||||
...queryKeys.issues.search(selectedCompanyId!, normalizedIssueSearch, projectId),
|
||||
searchFilters ?? {},
|
||||
],
|
||||
queryFn: () => issuesApi.list(selectedCompanyId!, { q: normalizedIssueSearch, projectId, ...searchFilters }),
|
||||
enabled: !!selectedCompanyId && normalizedIssueSearch.length > 0,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -573,9 +573,9 @@ export function AgentDetail() {
|
|||
});
|
||||
|
||||
const { data: allIssues } = useQuery({
|
||||
queryKey: queryKeys.issues.list(resolvedCompanyId!),
|
||||
queryFn: () => issuesApi.list(resolvedCompanyId!),
|
||||
enabled: !!resolvedCompanyId && needsDashboardData,
|
||||
queryKey: [...queryKeys.issues.list(resolvedCompanyId!), "participant-agent", resolvedAgentId ?? "__none__"],
|
||||
queryFn: () => issuesApi.list(resolvedCompanyId!, { participantAgentId: resolvedAgentId! }),
|
||||
enabled: !!resolvedCompanyId && !!resolvedAgentId && needsDashboardData,
|
||||
});
|
||||
|
||||
const { data: allAgents } = useQuery({
|
||||
|
|
@ -593,7 +593,6 @@ export function AgentDetail() {
|
|||
});
|
||||
|
||||
const assignedIssues = (allIssues ?? [])
|
||||
.filter((i) => i.assigneeAgentId === agent?.id)
|
||||
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
||||
const reportsToAgent = (allAgents ?? []).find((a) => a.id === agent?.reportsTo);
|
||||
const directReports = (allAgents ?? []).filter((a) => a.reportsTo === agent?.id && a.status !== "terminated");
|
||||
|
|
@ -1175,12 +1174,15 @@ function AgentOverview({
|
|||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium">Recent Issues</h3>
|
||||
<Link to={`/issues?assignee=${agentId}`} className="text-xs text-muted-foreground hover:text-foreground transition-colors">
|
||||
<Link
|
||||
to={`/issues?participantAgentId=${agentId}`}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
See All →
|
||||
</Link>
|
||||
</div>
|
||||
{assignedIssues.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No assigned issues.</p>
|
||||
<p className="text-sm text-muted-foreground">No recent issues.</p>
|
||||
) : (
|
||||
<div className="border border-border rounded-lg">
|
||||
{assignedIssues.slice(0, 10).map((issue) => (
|
||||
|
|
|
|||
|
|
@ -77,9 +77,64 @@ export function InstanceSettings() {
|
|||
},
|
||||
});
|
||||
|
||||
const disableAllMutation = useMutation({
|
||||
mutationFn: async (agentRows: InstanceSchedulerHeartbeatAgent[]) => {
|
||||
const enabled = agentRows.filter((a) => a.heartbeatEnabled);
|
||||
if (enabled.length === 0) return enabled;
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
enabled.map(async (agentRow) => {
|
||||
const agent = await agentsApi.get(agentRow.id, agentRow.companyId);
|
||||
const runtimeConfig = asRecord(agent.runtimeConfig) ?? {};
|
||||
const heartbeat = asRecord(runtimeConfig.heartbeat) ?? {};
|
||||
await agentsApi.update(
|
||||
agentRow.id,
|
||||
{
|
||||
runtimeConfig: {
|
||||
...runtimeConfig,
|
||||
heartbeat: { ...heartbeat, enabled: false },
|
||||
},
|
||||
},
|
||||
agentRow.companyId,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const failures = results.filter((result): result is PromiseRejectedResult => result.status === "rejected");
|
||||
if (failures.length > 0) {
|
||||
const firstError = failures[0]?.reason;
|
||||
const detail = firstError instanceof Error ? firstError.message : "Unknown error";
|
||||
throw new Error(
|
||||
failures.length === 1
|
||||
? `Failed to disable 1 timer heartbeat: ${detail}`
|
||||
: `Failed to disable ${failures.length} of ${enabled.length} timer heartbeats. First error: ${detail}`,
|
||||
);
|
||||
}
|
||||
return enabled;
|
||||
},
|
||||
onSuccess: async (updatedRows) => {
|
||||
setActionError(null);
|
||||
const companies = new Set(updatedRows.map((row) => row.companyId));
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.instance.schedulerHeartbeats }),
|
||||
...Array.from(companies, (companyId) =>
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(companyId) }),
|
||||
),
|
||||
...updatedRows.map((row) =>
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(row.id) }),
|
||||
),
|
||||
]);
|
||||
},
|
||||
onError: (error) => {
|
||||
setActionError(error instanceof Error ? error.message : "Failed to disable all heartbeats.");
|
||||
},
|
||||
});
|
||||
|
||||
const agents = heartbeatsQuery.data ?? [];
|
||||
const activeCount = agents.filter((agent) => agent.schedulerActive).length;
|
||||
const disabledCount = agents.length - activeCount;
|
||||
const enabledCount = agents.filter((agent) => agent.heartbeatEnabled).length;
|
||||
const anyEnabled = enabledCount > 0;
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<string, { companyName: string; agents: InstanceSchedulerHeartbeatAgent[] }>();
|
||||
|
|
@ -120,10 +175,27 @@ export function InstanceSettings() {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<span><span className="font-semibold text-foreground">{activeCount}</span> active</span>
|
||||
<span><span className="font-semibold text-foreground">{disabledCount}</span> disabled</span>
|
||||
<span><span className="font-semibold text-foreground">{grouped.length}</span> {grouped.length === 1 ? "company" : "companies"}</span>
|
||||
{anyEnabled && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="ml-auto h-7 text-xs"
|
||||
disabled={disableAllMutation.isPending}
|
||||
onClick={() => {
|
||||
const noun = enabledCount === 1 ? "agent" : "agents";
|
||||
if (!window.confirm(`Disable timer heartbeats for all ${enabledCount} enabled ${noun}?`)) {
|
||||
return;
|
||||
}
|
||||
disableAllMutation.mutate(agents);
|
||||
}}
|
||||
>
|
||||
{disableAllMutation.isPending ? "Disabling..." : "Disable All"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{actionError && (
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export function Issues() {
|
|||
const queryClient = useQueryClient();
|
||||
|
||||
const initialSearch = searchParams.get("q") ?? "";
|
||||
const participantAgentId = searchParams.get("participantAgentId") ?? undefined;
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const handleSearchChange = useCallback((search: string) => {
|
||||
clearTimeout(debounceRef.current);
|
||||
|
|
@ -86,8 +87,8 @@ export function Issues() {
|
|||
}, [setBreadcrumbs]);
|
||||
|
||||
const { data: issues, isLoading, error } = useQuery({
|
||||
queryKey: queryKeys.issues.list(selectedCompanyId!),
|
||||
queryFn: () => issuesApi.list(selectedCompanyId!),
|
||||
queryKey: [...queryKeys.issues.list(selectedCompanyId!), "participant-agent", participantAgentId ?? "__all__"],
|
||||
queryFn: () => issuesApi.list(selectedCompanyId!, { participantAgentId }),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
|
|
@ -117,6 +118,7 @@ export function Issues() {
|
|||
initialSearch={initialSearch}
|
||||
onSearchChange={handleSearchChange}
|
||||
onUpdateIssue={(id, data) => updateIssue.mutate({ id, data })}
|
||||
searchFilters={participantAgentId ? { participantAgentId } : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue