d9f91576a0
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies, so planning approvals and child-issue fan-out are part of the core control-plane loop. > - Accepted plans are supposed to be a safe bridge from planning into execution, especially when agents wake from review decisions and reuse isolated workspaces. > - The duplicate-subtask incident showed that an accepted plan revision could be interpreted more than once across overlapping runs, which broke the single-source-of-truth model for issue decomposition. > - Fixing that required tightening the backend contract first: accepted-plan decomposition needs an exact-once fingerprint, durable claim state, and retry-safe child creation. > - Once that backend behavior existed, the board still needed visibility into what happened, so the issue detail view needed a dedicated decomposition section instead of forcing operators to reconstruct child creation from raw activity. > - This pull request adds the exact-once decomposition primitive, hardens wake routing and regressions around the incident, and surfaces decomposition state in the UI so future incidents are both prevented and easier to inspect. ## What Changed - Added accepted-plan decomposition semantics to `doc/execution-semantics.md`, including the exact-once fingerprint, durable claim/result expectations, and retry/resume behavior. - Added persistent accepted-plan decomposition claims in the backend, including schema, shared types/validators, service logic, and issue routes for creating and listing decomposition state. - Hardened heartbeat routing so an accepted-plan continuation stays scoped to the relevant planning issue instead of opportunistically re-decomposing another accepted issue on the same assignee. - Added regression coverage for the original failure modes: concurrent same-parent retries, cross-issue accepted-plan isolation, and partial child recreation under the same fingerprint. - Added the `Plan decomposition` issue-detail section plus supporting API/query-key/activity formatting updates so operators can see revision status, owner, child counts, and the linked child issues directly in the UI. - Included the small follow-up UI fix so the decomposition section still renders when the issue work mode is no longer `planning`. ## Verification - `pnpm --filter @paperclipai/server typecheck` - `pnpm --filter @paperclipai/ui typecheck` - `pnpm --filter @paperclipai/db typecheck` - `pnpm exec vitest run server/src/__tests__/issues-service.test.ts` - `pnpm exec vitest run server/src/__tests__/issues-service.test.ts -t "lists persisted decompositions with child issue summaries"` - `pnpm exec vitest run server/src/__tests__/issues-service.test.ts -t "accepted plan decomposition" server/src/__tests__/heartbeat-accepted-plan-workspace-refresh.test.ts server/src/__tests__/heartbeat-context-summary.test.ts` - Manual UI path: create a planning issue without an isolated execution workspace, add a `plan` document, accept the `request_confirmation`, let Paperclip create child issues, then reopen the parent issue detail page and confirm the `Plan decomposition` section shows the accepted revision, status, idempotent-claim badge, and child links. - Separate follow-up bug noted during manual UI validation: accepting a plan on an issue whose run never records `workspace_finalize` is tracked in `PAPA-445` and is not part of this PR’s fix scope. ## Risks - This adds a new migration and a large Drizzle snapshot update; reviewers should confirm the schema shape and generated metadata match the intended decomposition table. - The exact-once claim changes sit on the accepted-plan fan-out path, so regressions there could block legitimate child creation or mis-handle retries if the claim state machine is wrong. - The new UI only appears when decomposition records exist; reviewers should use the manual verification path above rather than expecting existing issues on a stale local instance to show the section automatically. - `PAPA-445` remains an open follow-up for the `workspace_finalize` accept gate when a planning handoff never records finalize; that bug can interfere with reproducing the UI flow on isolated workspaces but does not change the correctness of the exact-once decomposition feature itself. > Checked `ROADMAP.md`: this PR is a bug fix / control-plane hardening change for accepted-plan decomposition, not a new uncoordinated roadmap feature. ## Model Used - OpenAI Codex via Paperclip `codex_local` (GPT-5-based coding agent; exact backend model ID/context window not exposed in the run context), with repository tool use, shell execution, and code-editing capabilities. <img width="806" height="1069" alt="Screenshot 2026-05-27 at 11 05 48 PM" src="https://github.com/user-attachments/assets/5b00b670-96cd-4470-b0a3-581743bcae28" /> ## 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 - [x] 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>
459 lines
18 KiB
TypeScript
459 lines
18 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { Clock, FlaskConical, Play, Search } from "lucide-react";
|
|
import type {
|
|
IssueGraphLivenessAutoRecoveryPreview,
|
|
PatchInstanceExperimentalSettings,
|
|
} from "@paperclipai/shared";
|
|
import { instanceSettingsApi } from "@/api/instanceSettings";
|
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
|
import { queryKeys } from "../lib/queryKeys";
|
|
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
|
|
function issueHref(identifier: string | null, issueId: string) {
|
|
if (!identifier) return `/issues/${issueId}`;
|
|
const prefix = identifier.split("-")[0] || "PAP";
|
|
return `/${prefix}/issues/${identifier}`;
|
|
}
|
|
|
|
function formatRecoveryState(state: string) {
|
|
return state.replace(/_/g, " ");
|
|
}
|
|
|
|
function RecoveryPreviewDialog({
|
|
preview,
|
|
open,
|
|
onOpenChange,
|
|
onEnableOnly,
|
|
onEnableAndRun,
|
|
isPending,
|
|
}: {
|
|
preview: IssueGraphLivenessAutoRecoveryPreview | null;
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
onEnableOnly: () => void;
|
|
onEnableAndRun: () => void;
|
|
isPending: boolean;
|
|
}) {
|
|
const count = preview?.recoverableFindings ?? 0;
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="sm:max-w-3xl">
|
|
<DialogHeader>
|
|
<DialogTitle>Confirm auto-recovery</DialogTitle>
|
|
<DialogDescription>
|
|
{preview
|
|
? `${count} recovery ${count === 1 ? "task" : "tasks"} match the last ${preview.lookbackHours} hours.`
|
|
: "Checking recovery candidates before enabling."}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="max-h-[min(28rem,65vh)] space-y-3 overflow-y-auto pr-1">
|
|
{preview && preview.items.length === 0 ? (
|
|
<div className="rounded-md border border-border bg-muted/30 px-3 py-4 text-sm text-muted-foreground">
|
|
No recovery tasks would be created right now. Auto-recovery can still run for future liveness incidents in
|
|
this window.
|
|
</div>
|
|
) : null}
|
|
|
|
{preview?.items.map((item) => (
|
|
<div key={item.incidentKey} className="rounded-md border border-border bg-card px-3 py-3">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<a
|
|
href={issueHref(item.identifier, item.issueId)}
|
|
className="text-sm font-medium text-primary underline-offset-2 hover:underline"
|
|
>
|
|
{item.identifier ?? item.issueId}
|
|
</a>
|
|
<span className="rounded-sm bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
|
|
{formatRecoveryState(item.state)}
|
|
</span>
|
|
</div>
|
|
<p className="mt-1 text-sm text-foreground">{item.title}</p>
|
|
<p className="mt-1 text-xs text-muted-foreground">{item.reason}</p>
|
|
<div className="mt-2 text-xs text-muted-foreground">
|
|
Recovery target:{" "}
|
|
<a
|
|
href={issueHref(item.recoveryIdentifier, item.recoveryIssueId)}
|
|
className="text-primary underline-offset-2 hover:underline"
|
|
>
|
|
{item.recoveryIdentifier ?? item.recoveryIssueId}
|
|
</a>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{preview && preview.skippedOutsideLookback > 0 ? (
|
|
<p className="text-xs text-muted-foreground">
|
|
{preview.skippedOutsideLookback} current{" "}
|
|
{preview.skippedOutsideLookback === 1 ? "finding is" : "findings are"} outside the configured lookback and
|
|
will not be touched.
|
|
</p>
|
|
) : null}
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isPending}>
|
|
Cancel
|
|
</Button>
|
|
<Button variant="outline" onClick={onEnableOnly} disabled={isPending || !preview}>
|
|
Enable only
|
|
</Button>
|
|
<Button onClick={onEnableAndRun} disabled={isPending || !preview}>
|
|
{count > 0 ? `Enable and create ${count}` : "Enable"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
export function InstanceExperimentalSettings() {
|
|
const { setBreadcrumbs } = useBreadcrumbs();
|
|
const queryClient = useQueryClient();
|
|
const [actionError, setActionError] = useState<string | null>(null);
|
|
const [lookbackHoursDraft, setLookbackHoursDraft] = useState("24");
|
|
const [previewDialogOpen, setPreviewDialogOpen] = useState(false);
|
|
const [pendingPreview, setPendingPreview] = useState<IssueGraphLivenessAutoRecoveryPreview | null>(null);
|
|
|
|
useEffect(() => {
|
|
setBreadcrumbs([
|
|
{ label: "Instance Settings" },
|
|
{ label: "Experimental" },
|
|
]);
|
|
}, [setBreadcrumbs]);
|
|
|
|
const experimentalQuery = useQuery({
|
|
queryKey: queryKeys.instance.experimentalSettings,
|
|
queryFn: () => instanceSettingsApi.getExperimental(),
|
|
});
|
|
|
|
const toggleMutation = useMutation({
|
|
mutationFn: async (patch: PatchInstanceExperimentalSettings) =>
|
|
instanceSettingsApi.updateExperimental(patch),
|
|
onSuccess: async () => {
|
|
setActionError(null);
|
|
await Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.instance.experimentalSettings }),
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.health }),
|
|
]);
|
|
},
|
|
onError: (error) => {
|
|
setActionError(error instanceof Error ? error.message : "Failed to update experimental settings.");
|
|
},
|
|
});
|
|
|
|
const previewMutation = useMutation({
|
|
mutationFn: async (lookbackHours: number) =>
|
|
instanceSettingsApi.previewIssueGraphLivenessAutoRecovery({ lookbackHours }),
|
|
onSuccess: (preview) => {
|
|
setActionError(null);
|
|
setPendingPreview(preview);
|
|
setPreviewDialogOpen(true);
|
|
},
|
|
onError: (error) => {
|
|
setActionError(error instanceof Error ? error.message : "Failed to preview recovery tasks.");
|
|
},
|
|
});
|
|
|
|
const runRecoveryMutation = useMutation({
|
|
mutationFn: async (lookbackHours: number) =>
|
|
instanceSettingsApi.runIssueGraphLivenessAutoRecovery({ lookbackHours }),
|
|
onSuccess: async () => {
|
|
setActionError(null);
|
|
setPreviewDialogOpen(false);
|
|
await Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.instance.experimentalSettings }),
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.health }),
|
|
]);
|
|
},
|
|
onError: (error) => {
|
|
setActionError(error instanceof Error ? error.message : "Failed to create recovery tasks.");
|
|
},
|
|
});
|
|
|
|
useEffect(() => {
|
|
const next = experimentalQuery.data?.issueGraphLivenessAutoRecoveryLookbackHours;
|
|
if (typeof next === "number") {
|
|
setLookbackHoursDraft(String(next));
|
|
}
|
|
}, [experimentalQuery.data?.issueGraphLivenessAutoRecoveryLookbackHours]);
|
|
|
|
if (experimentalQuery.isLoading) {
|
|
return <div className="text-sm text-muted-foreground">Loading experimental settings...</div>;
|
|
}
|
|
|
|
if (experimentalQuery.error) {
|
|
return (
|
|
<div className="text-sm text-destructive">
|
|
{experimentalQuery.error instanceof Error
|
|
? experimentalQuery.error.message
|
|
: "Failed to load experimental settings."}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const enableEnvironments = experimentalQuery.data?.enableEnvironments === true;
|
|
const enableIsolatedWorkspaces = experimentalQuery.data?.enableIsolatedWorkspaces === true;
|
|
const enableIssuePlanDecompositions =
|
|
experimentalQuery.data?.enableIssuePlanDecompositions === true;
|
|
const enableCloudSync = experimentalQuery.data?.enableCloudSync === true;
|
|
const autoRestartDevServerWhenIdle = experimentalQuery.data?.autoRestartDevServerWhenIdle === true;
|
|
const enableIssueGraphLivenessAutoRecovery =
|
|
experimentalQuery.data?.enableIssueGraphLivenessAutoRecovery === true;
|
|
const lookbackHours =
|
|
experimentalQuery.data?.issueGraphLivenessAutoRecoveryLookbackHours ?? 24;
|
|
const parsedLookbackHours = Number.parseInt(lookbackHoursDraft, 10);
|
|
const lookbackHoursIsValid =
|
|
Number.isInteger(parsedLookbackHours) && parsedLookbackHours >= 1 && parsedLookbackHours <= 720;
|
|
const recoveryActionPending =
|
|
toggleMutation.isPending || previewMutation.isPending || runRecoveryMutation.isPending;
|
|
|
|
function previewForEnable() {
|
|
if (!lookbackHoursIsValid) {
|
|
setActionError("Lookback hours must be a whole number from 1 to 720.");
|
|
return;
|
|
}
|
|
previewMutation.mutate(parsedLookbackHours);
|
|
}
|
|
|
|
function enableOnly() {
|
|
if (!lookbackHoursIsValid) return;
|
|
toggleMutation.mutate({
|
|
enableIssueGraphLivenessAutoRecovery: true,
|
|
issueGraphLivenessAutoRecoveryLookbackHours: parsedLookbackHours,
|
|
}, {
|
|
onSuccess: () => setPreviewDialogOpen(false),
|
|
});
|
|
}
|
|
|
|
function enableAndRun() {
|
|
if (!lookbackHoursIsValid) return;
|
|
toggleMutation.mutate({
|
|
enableIssueGraphLivenessAutoRecovery: true,
|
|
issueGraphLivenessAutoRecoveryLookbackHours: parsedLookbackHours,
|
|
}, {
|
|
onSuccess: () => runRecoveryMutation.mutate(parsedLookbackHours),
|
|
});
|
|
}
|
|
|
|
return (
|
|
<div className="max-w-4xl space-y-6">
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-2">
|
|
<FlaskConical className="h-5 w-5 text-muted-foreground" />
|
|
<h1 className="text-lg font-semibold">Experimental</h1>
|
|
</div>
|
|
<p className="text-sm text-muted-foreground">
|
|
Opt into features that are still being evaluated before they become default behavior.
|
|
</p>
|
|
</div>
|
|
|
|
{actionError && (
|
|
<div className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
|
{actionError}
|
|
</div>
|
|
)}
|
|
|
|
<section className="rounded-xl border border-border bg-card p-5">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div className="space-y-1.5">
|
|
<h2 className="text-sm font-semibold">Enable Environments</h2>
|
|
<p className="max-w-2xl text-sm text-muted-foreground">
|
|
Show environment management in company settings and allow project and agent environment assignment
|
|
controls.
|
|
</p>
|
|
</div>
|
|
<ToggleSwitch
|
|
checked={enableEnvironments}
|
|
onCheckedChange={() => toggleMutation.mutate({ enableEnvironments: !enableEnvironments })}
|
|
disabled={toggleMutation.isPending}
|
|
aria-label="Toggle environments experimental setting"
|
|
/>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="rounded-xl border border-border bg-card p-5">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div className="space-y-1.5">
|
|
<h2 className="text-sm font-semibold">Enable Isolated Workspaces</h2>
|
|
<p className="max-w-2xl text-sm text-muted-foreground">
|
|
Show execution workspace controls in project configuration and allow isolated workspace behavior for new
|
|
and existing issue runs.
|
|
</p>
|
|
</div>
|
|
<ToggleSwitch
|
|
checked={enableIsolatedWorkspaces}
|
|
onCheckedChange={() => toggleMutation.mutate({ enableIsolatedWorkspaces: !enableIsolatedWorkspaces })}
|
|
disabled={toggleMutation.isPending}
|
|
aria-label="Toggle isolated workspaces experimental setting"
|
|
/>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="rounded-xl border border-border bg-card p-5">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div className="space-y-1.5">
|
|
<h2 className="text-sm font-semibold">Issue Plan Decomposition Panel</h2>
|
|
<p className="max-w-2xl text-sm text-muted-foreground">
|
|
Show accepted-plan decomposition history on issue detail pages. Intended for debugging and validating
|
|
subtask creation behavior while the presentation is still being refined.
|
|
</p>
|
|
</div>
|
|
<ToggleSwitch
|
|
checked={enableIssuePlanDecompositions}
|
|
onCheckedChange={() =>
|
|
toggleMutation.mutate({
|
|
enableIssuePlanDecompositions: !enableIssuePlanDecompositions,
|
|
})
|
|
}
|
|
disabled={toggleMutation.isPending}
|
|
aria-label="Toggle issue plan decomposition panel experimental setting"
|
|
/>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="rounded-xl border border-border bg-card p-5">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div className="space-y-1.5">
|
|
<h2 className="text-sm font-semibold">Cloud Sync</h2>
|
|
<p className="max-w-2xl text-sm text-muted-foreground">
|
|
Show local Paperclip Cloud upstream connection, preview, push, retry, and activation review surfaces.
|
|
Saved connections and run history are preserved when this is disabled.
|
|
</p>
|
|
</div>
|
|
<ToggleSwitch
|
|
checked={enableCloudSync}
|
|
onCheckedChange={() => toggleMutation.mutate({ enableCloudSync: !enableCloudSync })}
|
|
disabled={toggleMutation.isPending}
|
|
aria-label="Toggle cloud sync experimental setting"
|
|
/>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="rounded-xl border border-border bg-card p-5">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div className="space-y-1.5">
|
|
<h2 className="text-sm font-semibold">Auto-Restart Dev Server When Idle</h2>
|
|
<p className="max-w-2xl text-sm text-muted-foreground">
|
|
In `pnpm dev:once`, wait for all queued and running local agent runs to finish, then restart the server
|
|
automatically when backend changes or migrations make the current boot stale.
|
|
</p>
|
|
</div>
|
|
<ToggleSwitch
|
|
checked={autoRestartDevServerWhenIdle}
|
|
onCheckedChange={() => toggleMutation.mutate({ autoRestartDevServerWhenIdle: !autoRestartDevServerWhenIdle })}
|
|
disabled={toggleMutation.isPending}
|
|
aria-label="Toggle guarded dev-server auto-restart"
|
|
/>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="rounded-xl border border-border bg-card p-5">
|
|
<div className="flex flex-col gap-5">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div className="space-y-1.5">
|
|
<h2 className="text-sm font-semibold">Auto-Create Issue Recovery Tasks</h2>
|
|
<p className="max-w-2xl text-sm text-muted-foreground">
|
|
Let the heartbeat scheduler create recovery issues for issue dependency chains found inside the
|
|
configured lookback window.
|
|
</p>
|
|
</div>
|
|
<ToggleSwitch
|
|
checked={enableIssueGraphLivenessAutoRecovery}
|
|
onCheckedChange={() => {
|
|
if (enableIssueGraphLivenessAutoRecovery) {
|
|
toggleMutation.mutate({ enableIssueGraphLivenessAutoRecovery: false });
|
|
return;
|
|
}
|
|
previewForEnable();
|
|
}}
|
|
disabled={recoveryActionPending}
|
|
aria-label="Toggle issue graph liveness auto-recovery"
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid gap-3 sm:grid-cols-[minmax(10rem,14rem)_1fr] sm:items-end">
|
|
<label className="space-y-1.5">
|
|
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
|
<Clock className="h-3.5 w-3.5" />
|
|
Lookback hours
|
|
</span>
|
|
<Input
|
|
type="number"
|
|
min={1}
|
|
max={720}
|
|
step={1}
|
|
value={lookbackHoursDraft}
|
|
onChange={(event) => setLookbackHoursDraft(event.target.value)}
|
|
aria-invalid={!lookbackHoursIsValid}
|
|
/>
|
|
</label>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => {
|
|
if (!lookbackHoursIsValid) {
|
|
setActionError("Lookback hours must be a whole number from 1 to 720.");
|
|
return;
|
|
}
|
|
toggleMutation.mutate({
|
|
issueGraphLivenessAutoRecoveryLookbackHours: parsedLookbackHours,
|
|
});
|
|
}}
|
|
disabled={recoveryActionPending || parsedLookbackHours === lookbackHours}
|
|
>
|
|
Save hours
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
onClick={previewForEnable}
|
|
disabled={recoveryActionPending}
|
|
>
|
|
<Search className="h-4 w-4" />
|
|
Preview
|
|
</Button>
|
|
<Button
|
|
onClick={() => {
|
|
if (!lookbackHoursIsValid) {
|
|
setActionError("Lookback hours must be a whole number from 1 to 720.");
|
|
return;
|
|
}
|
|
runRecoveryMutation.mutate(parsedLookbackHours);
|
|
}}
|
|
disabled={recoveryActionPending || !enableIssueGraphLivenessAutoRecovery}
|
|
>
|
|
<Play className="h-4 w-4" />
|
|
Run now
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
Current window: last {lookbackHours} {lookbackHours === 1 ? "hour" : "hours"}.
|
|
</p>
|
|
</div>
|
|
</section>
|
|
|
|
<RecoveryPreviewDialog
|
|
open={previewDialogOpen}
|
|
onOpenChange={setPreviewDialogOpen}
|
|
preview={pendingPreview}
|
|
onEnableOnly={enableOnly}
|
|
onEnableAndRun={enableAndRun}
|
|
isPending={recoveryActionPending}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|