mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-07 06:16:06 -10:00
fix(ui): explain monitor waits during active runs (#19519)
## Thinking Path > - Paperclip manages work performed by AI agents. > - Task monitors schedule later checks through execution admission. > - A native run must save its work before a monitor starts another run. > - The monitor banner can show an overdue warning during this valid wait. > - It also promises the next tick and offers a check that cannot start. > - This change explains the active run hold on both monitor surfaces. > - Users can see why the check waits without changing execution guards. ## Linked Issues or Issue Description Refs #16975 and #17863. The branch also preserves the quiet workspace-wait behavior from #19507. These changes added the native settlement guard and monitor deadline worker. A search found no open PR for this display case. **What happened?** A monitor became due while its native run was still saving work. The banner showed an overdue warning and “Fires on next tick.” The check later ran. The warning did not explain the execution hold. **Expected behavior** Show the active run wait. Keep the saved check time. Restore the normal display when the run clears. **Steps to reproduce** 1. Keep a task run active and set a monitor on that task. 2. Let the saved check time pass. 3. Inspect the monitor banner and composer strip. **Paperclip version or commit** Reproduced in mounted production components ata434edf991. The branch also includes current master. **Deployment mode** Source checkout on Linux. PostgreSQL tests use an isolated embedded database. No staging deployment is part of this PR. ## What Changed - Pass the existing task live-run state to both monitor surfaces. - Explain the current run wait. Keep its tone neutral and its saved check time visible. - Hide Check now during the hold. Restore it when the run clears. - Keep pending GitHub PR links visible during the hold. Restore Check status after the run clears. - Preserve blocked-task, retry, and idle-overdue display behavior. - Add rendered regressions and PostgreSQL finalization-release tests. - Document the distinction between a deadline and admission. The monitor display owns this change. The page already reads task run state, so this adds no API request. The scheduler and its guards are unchanged. The PostgreSQL tests confirm existing behavior. They are not evidence of a repaired scheduler fault. ## Verification - Before the repair, all three new rendered cases failed: future, due-now, and two-minute-overdue monitors during an active run. - After the repair, 50 UI tests pass across IssueMonitorBanner and issue-monitor. The same tests check return to normal display when the run clears. - Deadline, scheduled-work, and delivery-coordinator tests pass: 50 tests. The combined five-file run before the final safety assertion passed 99 tests. - PostgreSQL regressions cover release of committed and terminal_failure holds, rollback, one admitted wake, and quiet workers after settlement. - UI typecheck, token gates, and git diff --check pass. - A frozen install and plugin SDK dependency build were required for server test collection. The first collection attempt could not resolve the unbuilt SDK. That attempt had no test verdict. - At initial headee030bdf22, the focused three-file run passes 52 tests: 50 UI tests plus both PostgreSQL finalization-release cases. UI typecheck and token gates pass again after the master merge. - The complete PostgreSQL scheduling suite passes all 56 tests before the master merge. The earlier 54-test baseline also passes. - A real local task page passes in Chromium with fresh embedded PostgreSQL and a process run. Both monitor surfaces explain the hold. Check now stays hidden. Advancing the client clock past the deadline keeps the neutral wait. Reload preserves it. No page errors occur. - The first browser probe used an already-due monitor on a legacy process fixture. That path consumed the schedule before the page loaded, so the expected banner was absent. The corrected probe saves a future monitor and advances only the client clock. It verifies the display; it does not claim native scheduling acceptance. - [CI run 42260573923](https://github.com/paperclipai/paperclip/actions/runs/42260573923) passed foree030bdf22. A later review found hidden PR links during an active run;c3604bf4b4repairs that case. That revision later needed a master conflict repair before build/test CI could start. Final head is 7fe2417e4005378a8c2aee9451ae14f6f49c54ff; its required checks have completed successfully. The incident data establishes an active-run hold at the first due time. It does not fully explain the later interval between terminal status and monitor admission. This PR fixes the confirmed display defect. It does not claim to repair an unproven lost wake. - The review regression failed first in two cases with no PR links: future and overdue GitHub monitors during an active run. After the repair, 52 UI tests pass. They cover links on both surfaces, held check actions, return to Check status, and removal of merged PR links. UI typecheck and token gates also pass. - Current master was merged at7fe2417e40. Both the PR-link tests and master’s quiet workspace-wait tests are retained. All 65 tests across IssueMonitorBanner, issue-monitor, and IssueScheduledRetryCard pass. UI typecheck, token gates, and diff checks pass. [Final-head CI](https://github.com/paperclipai/paperclip/actions/runs/42267525907) completed successfully: 55 successful checks, three skipped, and one neutral. Build, Typecheck + Release Registry, and ci / verify passed. Greptile reviewed this exact head at 5/5 with no actionable findings. The prior reviewer thread is resolved; no review threads remain open. The real Chromium acceptance above was performed at ee030bdf22; the final link handling and merged workspace-wait cases are covered by the mounted component tests and fresh CI. ## Risks - The wait display depends on the page’s existing live-run query. A stale query can briefly keep the wait visible. - A monitor with no active run still shows an overdue warning. A blocked task keeps its existing pause controls. - No schema, scheduling, ownership, or authorization change. ## Model Used OpenAI Codex, GPT-6 family, with repository inspection, code editing, shell tools, and test execution. The exact serving snapshot and context-window size are not exposed in this session. ## 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 searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <[email protected]>
This commit is contained in:
1 parent
a08f9494ea
commit
c66b4a55ef
5 files changed
+126
-8
No files matched your search
@@ -12,6 +12,13 @@ already-known monitor deadline can fire earlier than the batching window.
|
||||
After all work settles, no periodic monitor scan remains. Pending claims and
|
||||
failed or deferred admissions retain their bounded deadlines.
|
||||
|
||||
A monitor deadline is the earliest time for a check. An active native run or an
|
||||
unfinished finalization holds the check until the prior work settles. The task
|
||||
banner explains the active run wait instead of promising the next scheduler tick
|
||||
or showing it as overdue. Once the run clears, the banner resumes its countdown
|
||||
and offers Check now. Finalization notifications restore monitor scheduling from
|
||||
the saved rows; they do not bypass execution admission.
|
||||
|
||||
Both idle drains and ordinary maintenance drains pause monitor dispatch. The
|
||||
worker retains its pending request without repeatedly reading monitor tables.
|
||||
After an explicit drain stop or TTL expiry, a bounded in-memory retry resumes
|
||||
|
||||
@@ -16,6 +16,7 @@ import { issueService } from "../issues.js";
|
||||
import { createDeliveryWorkCoordinator } from "../delivery-work-coordinator.js";
|
||||
import { DELIVERY_QUEUES } from "../delivery-work-notifications.js";
|
||||
import { registerScheduledWork } from "../scheduled-work.js";
|
||||
import { notifyNativeFinalizationWork } from "../native-finalization-notifications.js";
|
||||
import { idleWorkSnapshot, readTaskDrain, startTaskDrain, stopTaskDrain } from "../task-admission.js";
|
||||
import { conflict } from "../../errors.js";
|
||||
import { createHeartbeatRunPreparation } from "./run-preparation.js";
|
||||
@@ -585,6 +586,36 @@ describe.skipIf(!postgres.supported)("heartbeat scheduling database boundary", (
|
||||
} });
|
||||
}
|
||||
|
||||
it.each(["committed", "terminal_failure"] as const)("reactivates a due monitor after its last finalization hold becomes %s", async phase => {
|
||||
const f = await fixture();
|
||||
const [run] = await db.insert(heartbeatRuns).values({
|
||||
companyId: f.company.id, agentId: f.agent.id, nativeIssueId: f.issue.id,
|
||||
runtimeMode: "native", status: "succeeded", finishedAt: now,
|
||||
}).returning();
|
||||
await db.insert(nativeRunFinalizations).values({
|
||||
companyId: f.company.id, issueId: f.issue.id, runId: run!.id, phase: "arbitrating",
|
||||
});
|
||||
await start(f);
|
||||
await quiet();
|
||||
expect(f.deps.enqueueWakeup).not.toHaveBeenCalled();
|
||||
const settle = (owner: Db) => owner.transaction(async tx => {
|
||||
await notifyNativeFinalizationWork(tx, { companyId: f.company.id, runId: run!.id, issueId: f.issue.id });
|
||||
await tx.update(nativeRunFinalizations).set({ phase }).where(eq(nativeRunFinalizations.runId, run!.id));
|
||||
});
|
||||
await expect(db.transaction(async tx => {
|
||||
await settle(tx as unknown as Db);
|
||||
throw new Error("rollback finalization");
|
||||
})).rejects.toThrow("rollback finalization");
|
||||
await quiet();
|
||||
expect(f.deps.enqueueWakeup).not.toHaveBeenCalled();
|
||||
await settle(db);
|
||||
await advance(31_000);
|
||||
await vi.waitFor(async () => expect((await readIssue(f.issue.id)).monitorNextCheckAt).toBeNull());
|
||||
expect(f.deps.enqueueWakeup).toHaveBeenCalledOnce();
|
||||
expect(await readIssue(f.issue.id)).toMatchObject({ monitorAttemptCount: 1 });
|
||||
await quiet();
|
||||
});
|
||||
|
||||
it.each(["commit", "rollback"] as const)("reconciles worktree activation only after its outer transaction %s", async outcome => {
|
||||
const f = await fixture();
|
||||
const deadline = now.getTime() + 3_600_000;
|
||||
|
||||
@@ -33,7 +33,7 @@ describe("buildMonitorSurfaceCopy", () => {
|
||||
it.each(["retrying", "due-now", "overdue"] as const)("keeps workspace contention neutral when %s", (state) => {
|
||||
const copy = buildMonitorSurfaceCopy(derived({
|
||||
state, source: "scheduled-retry", nextCheckAt: NOW.toISOString(), attemptCount: 4,
|
||||
}), NOW, "workspace_busy");
|
||||
}), NOW, "workspace_busy", 0, "in_progress", true);
|
||||
expect(copy!.bannerTitle).toBe("Waiting for workspace");
|
||||
expect(copy!.stripTitle).toBe("Waiting for workspace");
|
||||
expect(copy!.tone).toBe("info");
|
||||
@@ -90,6 +90,14 @@ describe("buildMonitorSurfaceCopy", () => {
|
||||
expect(copy!.stripTitle).toBe("Resumes in 1h 30m");
|
||||
});
|
||||
|
||||
it("keeps blocked checks paused while a run is active", () => {
|
||||
const copy = buildMonitorSurfaceCopy(derived({ state: "overdue", nextCheckAt: NOW.toISOString() }),
|
||||
NOW, null, 0, "blocked", true);
|
||||
expect(copy!.bannerTitle).toBe("Automatic checks paused");
|
||||
expect(copy!.executionWait).toBeUndefined();
|
||||
expect(copy!.checkUnavailable).toBe(false);
|
||||
});
|
||||
|
||||
it("switches copy for due-now and overdue states", () => {
|
||||
const dueNow = buildMonitorSurfaceCopy(
|
||||
derived({ state: "due-now", nextCheckAt: NOW.toISOString(), attemptCount: 1 }),
|
||||
@@ -146,6 +154,27 @@ describe("IssueMonitorBanner / IssueMonitorComposerStrip rendering", () => {
|
||||
} as unknown as Issue;
|
||||
}
|
||||
|
||||
it.each([-120_000, 0, 120_000])("explains the active run hold on both monitor surfaces (offset: %s)", offset => {
|
||||
const issue = { ...issueWithMonitor(new Date(NOW.getTime() + offset).toISOString()), status: "in_progress" as const };
|
||||
const onCheckNow = vi.fn();
|
||||
const root = createRoot(container);
|
||||
const render = (hasActiveRun: boolean) => flushSync(() => root.render(<>
|
||||
<IssueMonitorBanner issue={issue} hasActiveRun={hasActiveRun} onCheckNow={onCheckNow} />
|
||||
<IssueMonitorComposerStrip issue={issue} hasActiveRun={hasActiveRun} onCheckNow={onCheckNow} />
|
||||
</>));
|
||||
try {
|
||||
render(true);
|
||||
expect(container.textContent).toContain("Monitor waits for current run");
|
||||
expect(container.textContent).toContain("The check can start after the current run finishes saving its work.");
|
||||
expect(container.textContent).not.toMatch(/overdue|next tick|Checking momentarily|wakes the agent now/i);
|
||||
expect(Array.from(container.querySelectorAll("button")).some(button => button.textContent === "Check now")).toBe(false);
|
||||
render(false);
|
||||
expect(container.textContent).not.toContain("Monitor waits for current run");
|
||||
expect(Array.from(container.querySelectorAll("button")).filter(button => button.textContent === "Check now")).toHaveLength(2);
|
||||
if (offset < 0) expect(container.textContent).toContain("overdue by 2m");
|
||||
} finally { flushSync(() => root.unmount()); }
|
||||
});
|
||||
|
||||
it("requires confirmation, keeps the monitor on dismissal, and reports cancellation errors", async () => {
|
||||
const issue = issueWithMonitor(new Date(NOW.getTime() + 60_000).toISOString());
|
||||
const onCancel = vi.fn().mockRejectedValueOnce(new Error("Unable to save monitor")).mockResolvedValueOnce(undefined);
|
||||
@@ -206,6 +235,34 @@ describe("IssueMonitorBanner / IssueMonitorComposerStrip rendering", () => {
|
||||
flushSync(() => root.unmount());
|
||||
});
|
||||
|
||||
it.each([-120_000, 120_000])("keeps pending PR links visible during an active run (offset: %s)", offset => {
|
||||
const issue = { ...issueWithMonitor(new Date(NOW.getTime() + offset).toISOString()), status: "in_progress" as const };
|
||||
issue.executionState!.monitor!.serviceName = "github";
|
||||
const product = {
|
||||
id: "pr-1", type: "pull_request", title: "Update runtime probe",
|
||||
url: "https://github.com/example/repo/pull/42", status: "ready_for_review",
|
||||
reviewState: "needs_board_review", updatedAt: NOW,
|
||||
} as unknown as IssueWorkProduct;
|
||||
const root = createRoot(container);
|
||||
const render = (hasActiveRun: boolean, products = [product]) => flushSync(() => root.render(<>
|
||||
<IssueMonitorBanner issue={issue} hasActiveRun={hasActiveRun} workProducts={products} onCheckNow={vi.fn()} />
|
||||
<IssueMonitorComposerStrip issue={issue} hasActiveRun={hasActiveRun} workProducts={products} onCheckNow={vi.fn()} />
|
||||
</>));
|
||||
try {
|
||||
render(true);
|
||||
expect(container.textContent).toContain("Monitor waits for current run");
|
||||
expect(Array.from(container.querySelectorAll("a")).map(link => link.href)).toEqual([product.url, product.url]);
|
||||
expect(container.querySelector("button")).toBeNull();
|
||||
expect(container.textContent).toContain("Review on GitHub while the current run finishes saving its work.");
|
||||
expect(container.textContent).not.toMatch(/overdue|next tick|then check status/i);
|
||||
render(false);
|
||||
expect(container.textContent).toContain("Pull request review requested");
|
||||
expect(Array.from(container.querySelectorAll("button")).map(button => button.textContent)).toEqual(["Check status", "Check status"]);
|
||||
render(true, [{ ...product, metadata: { state: "merged" } }]);
|
||||
expect(container.querySelector("a")).toBeNull();
|
||||
} finally { flushSync(() => root.unmount()); }
|
||||
});
|
||||
|
||||
it("does not raise a pinned banner for a workspace wait the user cannot act on (N-14)", () => {
|
||||
const issue = {
|
||||
status: "todo", scheduledRetry: { status: "scheduled_retry", scheduledRetryReason: "workspace_busy", scheduledRetryAt: NOW.toISOString() },
|
||||
|
||||
@@ -67,6 +67,7 @@ export interface MonitorSurfaceCopy {
|
||||
pullRequestReview?: boolean;
|
||||
automaticChecksPaused?: boolean;
|
||||
checkUnavailable?: boolean;
|
||||
executionWait?: boolean;
|
||||
}
|
||||
|
||||
function capitalize(value: string): string {
|
||||
@@ -84,6 +85,7 @@ export function buildMonitorSurfaceCopy(
|
||||
scheduledRetryReason?: string | null,
|
||||
pendingPullRequestReviews = 0,
|
||||
issueStatus?: string,
|
||||
hasActiveRun = false,
|
||||
): MonitorSurfaceCopy | null {
|
||||
if (!isWaitingMonitorState(derived.state) || !derived.nextCheckAt) return null;
|
||||
|
||||
@@ -116,7 +118,21 @@ export function buildMonitorSurfaceCopy(
|
||||
|
||||
const eta = formatMonitorEta(derived.nextCheckAt, now); // "in 2h 12m" | "due now" | "overdue by 18m"
|
||||
const absolute = formatMonitorAbsolute(derived.nextCheckAt, {}, now); // local time, e.g. "Today, 4:08 PM"
|
||||
if (derived.source === "monitor" && derived.serviceName?.toLowerCase() === "github" && pendingPullRequestReviews > 0) {
|
||||
const awaitingPullRequestReview = derived.source === "monitor" && derived.serviceName?.toLowerCase() === "github" && pendingPullRequestReviews > 0;
|
||||
if (derived.source === "monitor" && hasActiveRun) {
|
||||
const hint = "The check can start after the current run finishes saving its work.";
|
||||
return {
|
||||
bannerTitle: "Monitor waits for current run",
|
||||
stripTitle: "Monitor waits for current run",
|
||||
bannerMeta: [hint, `Scheduled for ${absolute} (your time)`],
|
||||
stripMeta: [hint, `Scheduled for ${absolute}`],
|
||||
tone: "info",
|
||||
executionWait: true,
|
||||
checkUnavailable: true,
|
||||
pullRequestReview: awaitingPullRequestReview,
|
||||
};
|
||||
}
|
||||
if (awaitingPullRequestReview) {
|
||||
return {
|
||||
bannerTitle: "Pull request review requested",
|
||||
stripTitle: "Pull request review requested",
|
||||
@@ -179,7 +195,7 @@ export function buildMonitorSurfaceCopy(
|
||||
};
|
||||
}
|
||||
|
||||
function useMonitorSurfaceCopy(issue: Issue, pendingPullRequestReviews: number): MonitorSurfaceCopy | null {
|
||||
function useMonitorSurfaceCopy(issue: Issue, pendingPullRequestReviews: number, hasActiveRun: boolean): MonitorSurfaceCopy | null {
|
||||
// `nextCheckAt` is stable for a given issue; derive once to seed the ticking
|
||||
// countdown cadence, then re-derive against the live clock so the surfaces
|
||||
// roll scheduled → due → overdue on their own.
|
||||
@@ -188,7 +204,7 @@ function useMonitorSurfaceCopy(issue: Issue, pendingPullRequestReviews: number):
|
||||
return derived.source === "monitor" && areAutomaticMonitorChecksPaused(issue) ? null : derived.nextCheckAt;
|
||||
}, [issue]);
|
||||
const now = useMonitorCountdown(nextCheckAt);
|
||||
return useMemo(() => buildMonitorSurfaceCopy(deriveMonitorState(issue, now), now, issue.scheduledRetry?.scheduledRetryReason, pendingPullRequestReviews, issue.status), [issue, now, pendingPullRequestReviews]);
|
||||
return useMemo(() => buildMonitorSurfaceCopy(deriveMonitorState(issue, now), now, issue.scheduledRetry?.scheduledRetryReason, pendingPullRequestReviews, issue.status, hasActiveRun), [issue, now, pendingPullRequestReviews, hasActiveRun]);
|
||||
}
|
||||
|
||||
function CheckNowButton({
|
||||
@@ -254,6 +270,7 @@ function CancelMonitorButton({ onCancel }: { onCancel: () => Promise<unknown> })
|
||||
|
||||
export interface IssueMonitorSurfaceProps {
|
||||
issue: Issue;
|
||||
hasActiveRun?: boolean;
|
||||
onCheckNow?: (() => void) | null;
|
||||
checkingNow?: boolean;
|
||||
workProducts?: IssueWorkProduct[] | null;
|
||||
@@ -268,6 +285,7 @@ export interface IssueMonitorSurfaceProps {
|
||||
*/
|
||||
export function IssueMonitorBanner({
|
||||
issue,
|
||||
hasActiveRun = false,
|
||||
onCheckNow = null,
|
||||
checkingNow = false,
|
||||
workProducts,
|
||||
@@ -275,7 +293,7 @@ export function IssueMonitorBanner({
|
||||
onCancelMonitor,
|
||||
}: IssueMonitorSurfaceProps) {
|
||||
const reviews = getIssuePullRequests(workProducts).filter(pullRequestNeedsReview);
|
||||
const copy = useMonitorSurfaceCopy(issue, reviews.length);
|
||||
const copy = useMonitorSurfaceCopy(issue, reviews.length, hasActiveRun);
|
||||
if (!copy) return null;
|
||||
|
||||
// A busy workspace is a transient system wait the user cannot act on. It stays
|
||||
@@ -309,6 +327,7 @@ export function IssueMonitorBanner({
|
||||
*/
|
||||
export function IssueMonitorComposerStrip({
|
||||
issue,
|
||||
hasActiveRun = false,
|
||||
onCheckNow = null,
|
||||
checkingNow = false,
|
||||
workProducts,
|
||||
@@ -316,7 +335,7 @@ export function IssueMonitorComposerStrip({
|
||||
className,
|
||||
}: IssueMonitorSurfaceProps & { className?: string }) {
|
||||
const reviews = getIssuePullRequests(workProducts).filter(pullRequestNeedsReview);
|
||||
const copy = useMonitorSurfaceCopy(issue, reviews.length);
|
||||
const copy = useMonitorSurfaceCopy(issue, reviews.length, hasActiveRun);
|
||||
if (!copy) return null;
|
||||
|
||||
return (
|
||||
@@ -338,9 +357,11 @@ export function IssueMonitorComposerStrip({
|
||||
</div>
|
||||
{!copy.automaticChecksPaused ? (
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||
{copy.pullRequestReview
|
||||
{copy.pullRequestReview && copy.executionWait
|
||||
? "Review on GitHub while the current run finishes saving its work."
|
||||
: copy.pullRequestReview
|
||||
? "Review on GitHub, then check status to have the agent verify the result."
|
||||
: copy.workspaceWait
|
||||
: copy.workspaceWait || copy.executionWait
|
||||
? "You can keep sending instructions while the agent waits."
|
||||
: "Sending a reply wakes the agent now — before the scheduled check."}
|
||||
</p>
|
||||
|
||||
@@ -8048,6 +8048,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
|
||||
<IssueMonitorBanner
|
||||
key={issue.id}
|
||||
hasActiveRun={hasLiveRuns}
|
||||
onCancelMonitor={() => cancelIssueMonitor.mutateAsync()}
|
||||
issue={issue}
|
||||
workProducts={workProducts}
|
||||
@@ -8629,6 +8630,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
hasVisibleMonitorSurface(issue) ? (
|
||||
<IssueMonitorComposerStrip
|
||||
issue={issue}
|
||||
hasActiveRun={hasLiveRuns}
|
||||
workProducts={workProducts}
|
||||
checkError={checkIssueMonitorNow.error ? describeError(checkIssueMonitorNow.error, { action: "check the monitor" }).body : null}
|
||||
onCheckNow={() => checkIssueMonitorNow.mutate()}
|
||||
|
||||
Reference in new issue
Block a user