[codex] Roll up May 17 branch changes (#6210)

## Thinking Path

> - Paperclip is the control plane for autonomous AI companies, so agent
work needs visible ownership, recovery, and operator controls.
> - This local branch had accumulated several related control-plane
reliability and operator-experience fixes across recovery actions,
watchdog folding, model-profile defaults, mentions, markdown editing,
plugin launchers, and small UI polish.
> - The branch needed to be converted into a PR against the current
`origin/master` without losing dirty work or including lockfile/workflow
churn.
> - The safest standalone shape is a single rollup PR because the
recovery/server/UI files overlap heavily across the local commits and
splitting would create avoidable conflicts.
> - This pull request replays the local branch onto latest
`origin/master`, preserves the uncommitted work as logical commits, and
adds a Zod 4 validator compatibility fix found during verification.
> - The benefit is that the May 17 local branch can be reviewed and
merged as one coherent, conflict-free branch under the 100-file Greptile
limit.

## What Changed

- Rebased the local May 17 branch work onto current `origin/master` in a
dedicated worktree.
- Preserved and committed previously dirty changes for recovery retry
handling, plugin/sidebar launcher polish, and `.herenow` ignores.
- Added recovery-action behavior for returning source issues to `todo`
when retrying source-scoped recovery.
- Included the existing local recovery/liveness/watchdog fold, Codex
cheap-profile, markdown/mention, duplicate-agent, and UI polish commits
from the branch.
- Normalized shared validator `z.record(...)` schemas to explicit
string-key records for Zod 4 compatibility.
- Confirmed the PR has no `pnpm-lock.yaml` or `.github/workflows/*`
changes and stays below the 100-file Greptile limit.

## Verification

- `pnpm install --frozen-lockfile --ignore-scripts`
- `npm run install` in
`node_modules/.pnpm/sqlite3@5.1.7/node_modules/sqlite3` to build the
local native sqlite3 binding after installing with scripts disabled
- `pnpm exec vitest run packages/shared/src/validators/issue.test.ts
packages/shared/src/project-mentions.test.ts
packages/adapter-utils/src/server-utils.test.ts
server/src/__tests__/heartbeat-model-profile.test.ts
server/src/__tests__/issue-recovery-actions.test.ts
server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts
server/src/__tests__/heartbeat-active-run-output-watchdog.test.ts
server/src/__tests__/plugin-local-folders.test.ts
ui/src/components/IssueRecoveryActionCard.test.tsx
ui/src/components/Sidebar.test.tsx
ui/src/components/SidebarAccountMenu.test.tsx
ui/src/components/IssueProperties.test.tsx
ui/src/components/MarkdownEditor.test.tsx
ui/src/components/MarkdownBody.test.tsx
ui/src/lib/duplicate-agent-payload.test.ts
ui/src/pages/Routines.test.tsx`
- First pass: 13 files passed with 201 passing tests; 3 server files
failed before sqlite3 native binding was built.
- After rebuilding sqlite3:
`server/src/__tests__/heartbeat-model-profile.test.ts`,
`server/src/__tests__/issue-recovery-actions.test.ts`, and
`server/src/__tests__/heartbeat-active-run-output-watchdog.test.ts`
passed/loaded; embedded Postgres tests were skipped by the local host
guard.
- `pnpm --filter @paperclipai/shared typecheck`
- `pnpm --filter @paperclipai/adapter-utils typecheck`
- `pnpm --filter @paperclipai/server typecheck`
- `pnpm --filter @paperclipai/ui typecheck`

## Risks

- Medium risk: this is a broad rollup PR across recovery semantics,
server tests, shared validators, and UI surfaces.
- Some embedded Postgres tests skipped locally due the host guard, so CI
should provide the stronger database-backed signal.
- UI changes were covered by component tests, but no browser screenshot
was captured in this PR creation pass.
- This branch may overlap with existing recovery/liveness PR work; merge
this PR independently or restack/close overlapping branches rather than
merging duplicate implementations together.

> 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, tool-enabled local repository
and GitHub workflow, medium reasoning effort.

## 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
- [x] 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:
Dotta 2026-05-17 17:15:06 -05:00 committed by GitHub
parent 705c1b8d81
commit d734bd43d1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
83 changed files with 3675 additions and 180 deletions

View file

@ -145,6 +145,8 @@ interface IssuePropertiesProps {
inline?: boolean;
}
const ISSUE_BLOCKER_SEARCH_LIMIT = 50;
function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex items-start gap-3 py-1.5">
@ -405,6 +407,7 @@ export function IssueProperties({
const [monitorAtInput, setMonitorAtInput] = useState(() => toDateTimeLocalValue(issue.executionPolicy?.monitor?.nextCheckAt));
const [monitorNotesInput, setMonitorNotesInput] = useState(issue.executionPolicy?.monitor?.notes ?? "");
const [monitorServiceInput, setMonitorServiceInput] = useState(issue.executionPolicy?.monitor?.serviceName ?? "");
const normalizedBlockedBySearch = blockedBySearch.trim();
const { data: session } = useQuery({
queryKey: queryKeys.auth.session,
@ -443,10 +446,21 @@ export function IssueProperties({
enabled: !!companyId,
});
const { data: allIssues } = useQuery({
const { data: allIssues, isFetching: isFetchingIssuePickerIssues } = useQuery({
queryKey: queryKeys.issues.list(companyId!),
queryFn: () => issuesApi.list(companyId!),
enabled: !!companyId && (blockedByOpen || parentOpen),
enabled: !!companyId && (parentOpen || (blockedByOpen && normalizedBlockedBySearch.length === 0)),
});
const { data: searchedBlockedByIssues, isFetching: isFetchingSearchedBlockedByIssues } = useQuery({
queryKey: companyId
? queryKeys.issues.search(companyId, normalizedBlockedBySearch, undefined, ISSUE_BLOCKER_SEARCH_LIMIT)
: ["issues", "blocker-search", normalizedBlockedBySearch, ISSUE_BLOCKER_SEARCH_LIMIT],
queryFn: () => issuesApi.list(companyId!, {
q: normalizedBlockedBySearch,
limit: ISSUE_BLOCKER_SEARCH_LIMIT,
}),
enabled: !!companyId && blockedByOpen && normalizedBlockedBySearch.length > 0,
});
const createLabel = useMutation({
@ -1648,27 +1662,28 @@ export function IssueProperties({
</>
);
const blockingIssues = issue.blocks ?? [];
const blockerOptions = (allIssues ?? [])
.filter((candidate) => candidate.id !== issue.id)
.filter((candidate) => {
if (!blockedBySearch.trim()) return true;
const query = blockedBySearch.toLowerCase();
return (
(candidate.identifier ?? "").toLowerCase().includes(query) ||
candidate.title.toLowerCase().includes(query)
);
})
.sort((a, b) => {
const blockerSearchActive = normalizedBlockedBySearch.length > 0;
const blockerSourceIssues = blockerSearchActive ? searchedBlockedByIssues : allIssues;
const blockerOptions = (blockerSourceIssues ?? [])
.filter((candidate) => candidate.id !== issue.id);
if (!blockerSearchActive) {
blockerOptions.sort((a, b) => {
const aLabel = `${a.identifier ?? ""} ${a.title}`.trim();
const bLabel = `${b.identifier ?? ""} ${b.title}`.trim();
return aLabel.localeCompare(bLabel);
});
}
const blockerOptionsLoading = blockedByOpen && (
blockerSearchActive ? isFetchingSearchedBlockedByIssues : isFetchingIssuePickerIssues
);
const toggleBlockedBy = (blockedByIssueId: string) => {
const nextBlockedByIds = blockedByIds.includes(blockedByIssueId)
? blockedByIds.filter((candidate) => candidate !== blockedByIssueId)
: [...blockedByIds, blockedByIssueId];
onUpdate({ blockedByIssueIds: nextBlockedByIds });
setBlockedByOpen(false);
setBlockedBySearch("");
};
const removeBlockedBy = (blockedByIssueId: string) => {
onUpdate({ blockedByIssueIds: blockedByIds.filter((candidate) => candidate !== blockedByIssueId) });
@ -1682,6 +1697,7 @@ export function IssueProperties({
value={blockedBySearch}
onChange={(e) => setBlockedBySearch(e.target.value)}
autoFocus={!inline}
aria-label="Search issues to add as blockers"
/>
<div className="max-h-48 overflow-y-auto overscroll-contain">
<button
@ -1689,7 +1705,11 @@ export function IssueProperties({
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
blockedByIds.length === 0 && "bg-accent",
)}
onClick={() => onUpdate({ blockedByIssueIds: [] })}
onClick={() => {
onUpdate({ blockedByIssueIds: [] });
setBlockedByOpen(false);
setBlockedBySearch("");
}}
>
No blockers
</button>
@ -1709,9 +1729,15 @@ export function IssueProperties({
{candidate.identifier ? `${candidate.identifier} ` : ""}
{candidate.title}
</span>
{selected && <Check className="ml-auto h-3.5 w-3.5 shrink-0 text-foreground" aria-hidden="true" />}
</button>
);
})}
{blockerOptionsLoading ? (
<div className="px-2 py-2 text-xs text-muted-foreground">Searching issues...</div>
) : blockerOptions.length === 0 ? (
<div className="px-2 py-2 text-xs text-muted-foreground">No matching issues.</div>
) : null}
</div>
</>
);