Re-nudge stalled automation and add manual Run now overrides

The automation watermarks made each nudge one-shot: fired against a
failing sha (or a comment count) exactly once, with no retry if the
agent was stopped when the prompt landed or the send never took —
leaving red CI and open comments with no agent working and no way to
re-trigger short of unchecking and re-checking.

Two escapes: while a condition persists (CI still failing / comments
still open) and NO turn is running in the chat, the armed automation
re-nudges after a 15-minute cooldown (timestamps join the stored
watermarks); and each automation row shows a "Run now" button whenever
its condition is active and the agent is idle, which fires the same
prompt immediately, watermarks aside.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
klopez4212
2026-07-07 07:51:59 +01:00
co-authored by Claude Fable 5
parent f3a035c424
commit 86cab382ae
4 changed files with 133 additions and 51 deletions
@@ -25,6 +25,10 @@ export type ChatWorkAutomation = {
lastCiNudgeSha: string | null;
/** Comment total at the last address-comments nudge. */
lastCommentNudgeCount: number | null;
/** Epoch ms of the last CI nudge — drives the persistent-failure re-nudge. */
lastCiNudgeAt: number | null;
/** Epoch ms of the last comment nudge. */
lastCommentNudgeAt: number | null;
};
const DEFAULTS: ChatWorkAutomation = {
@@ -32,6 +36,8 @@ const DEFAULTS: ChatWorkAutomation = {
addressComments: false,
lastCiNudgeSha: null,
lastCommentNudgeCount: null,
lastCiNudgeAt: null,
lastCommentNudgeAt: null,
};
function storageKey(chatId: string) {
@@ -59,6 +65,12 @@ export function readChatWorkAutomation(chatId: string): ChatWorkAutomation {
typeof parsed.lastCommentNudgeCount === "number"
? parsed.lastCommentNudgeCount
: null,
lastCiNudgeAt:
typeof parsed.lastCiNudgeAt === "number" ? parsed.lastCiNudgeAt : null,
lastCommentNudgeAt:
typeof parsed.lastCommentNudgeAt === "number"
? parsed.lastCommentNudgeAt
: null,
};
} catch {
return DEFAULTS;
@@ -723,6 +723,7 @@ export function ChatDetail({
<ChatWorkPanel
branch={workBranch}
chatId={chat.id}
isTurnActive={isChatTurnActive}
onAutomationPrompt={(content) =>
void onSend(content, [], [CHAT_AUTOMATION_TAG])
}
+116 -51
View File
@@ -37,6 +37,12 @@ const CHIP_CLASS =
const lastShownBranchByChat = new Map<string, string>();
const lastShownPrByChat = new Map<string, string>();
// A consumed watermark must not strand a persisting condition: if CI is
// still red (or comments still open) and NO turn is running, armed
// automation re-nudges after this cooldown — the earlier nudge may have
// landed while the agent was stopped or the message failed to take.
const RENUDGE_COOLDOWN_MS = 15 * 60_000;
/**
* Right-hand work drawer for a chat: branch, live PR card, and a CI monitor
* once the agent has produced a pull request; an empty state before that.
@@ -47,6 +53,7 @@ const lastShownPrByChat = new Map<string, string>();
export function ChatWorkPanel({
branch = null,
chatId,
isTurnActive = false,
onAutomationPrompt,
open = true,
prHref,
@@ -55,6 +62,8 @@ export function ChatWorkPanel({
/** Live branch from the agent's worktree/checkout activity, if any. */
branch?: string | null;
chatId: string;
/** Whether an agent turn is currently running in this chat. */
isTurnActive?: boolean;
onAutomationPrompt?: (content: string) => void;
open?: boolean;
prHref?: string | null;
@@ -120,43 +129,75 @@ export function ChatWorkPanel({
}
}, [chatId, preview?.href]);
const ciConditionActive = Boolean(
checks && checks.failed > 0 && checks.pending === 0,
);
const sendCiNudge = React.useCallback(() => {
if (!onAutomationPrompt || !effectiveHref || !pr || !checks) {
return;
}
updateChatWorkAutomation(chatId, {
lastCiNudgeSha: pr.headSha,
lastCiNudgeAt: Date.now(),
});
onAutomationPrompt(
`CI is failing on ${effectiveHref} (${checks.failed} of ${checks.total} checks). Investigate the failures and push fixes until the checks pass.`,
);
}, [chatId, checks, effectiveHref, onAutomationPrompt, pr]);
const sendCommentNudge = React.useCallback(() => {
if (!onAutomationPrompt || !effectiveHref) {
return;
}
updateChatWorkAutomation(chatId, {
lastCommentNudgeCount: Math.max(openThreads, 1),
lastCommentNudgeAt: Date.now(),
});
onAutomationPrompt(
`There are unanswered review comments on ${effectiveHref}. Address each comment and its replies, push any needed changes, reply to the threads, and resolve every conversation that has been addressed.`,
);
}, [chatId, effectiveHref, onAutomationPrompt, openThreads]);
// Automation: prompt the agent on CI failure / newly-open review threads.
// Watermarks in storage keep this to one nudge per failing sha and per
// rise in open threads; the thread watermark re-arms once everything has
// been replied to (count back at zero).
// Watermarks keep this to one nudge per failing sha and per rise in open
// threads (the thread watermark re-arms at zero); the cooldown path above
// covers conditions that persist with no agent working.
React.useEffect(() => {
if (!onAutomationPrompt || !effectiveHref || !pr) {
return;
}
if (
automation.autoFixCi &&
checks &&
checks.failed > 0 &&
checks.pending === 0 &&
automation.lastCiNudgeSha !== pr.headSha
) {
updateChatWorkAutomation(chatId, { lastCiNudgeSha: pr.headSha });
onAutomationPrompt(
`CI is failing on ${effectiveHref} (${checks.failed} of ${checks.total} checks). Investigate the failures and push fixes until the checks pass.`,
);
if (automation.autoFixCi && ciConditionActive && checks) {
const isNewFailure = automation.lastCiNudgeSha !== pr.headSha;
const cooledDown =
!isTurnActive &&
Date.now() - (automation.lastCiNudgeAt ?? 0) > RENUDGE_COOLDOWN_MS;
if (isNewFailure || cooledDown) {
sendCiNudge();
}
}
const threadWatermark = automation.lastCommentNudgeCount ?? 0;
if (openThreads === 0 && threadWatermark !== 0) {
updateChatWorkAutomation(chatId, { lastCommentNudgeCount: 0 });
} else if (automation.addressComments && openThreads > threadWatermark) {
updateChatWorkAutomation(chatId, { lastCommentNudgeCount: openThreads });
onAutomationPrompt(
`There are unanswered review comments on ${effectiveHref}. Address each comment and its replies, push any needed changes, reply to the threads, and resolve every conversation that has been addressed.`,
);
} else if (automation.addressComments && openThreads > 0) {
const isNewComment = openThreads > threadWatermark;
const cooledDown =
!isTurnActive &&
Date.now() - (automation.lastCommentNudgeAt ?? 0) > RENUDGE_COOLDOWN_MS;
if (isNewComment || cooledDown) {
sendCommentNudge();
}
}
}, [
automation,
chatId,
checks,
ciConditionActive,
effectiveHref,
isTurnActive,
onAutomationPrompt,
openThreads,
pr,
sendCiNudge,
sendCommentNudge,
]);
return (
@@ -234,38 +275,62 @@ export function ChatWorkPanel({
</span>
)}
<div aria-hidden="true" className="h-px bg-border/40" />
<label
className="flex cursor-pointer items-center gap-2"
htmlFor="automation-auto-fix-ci"
>
<Checkbox
checked={automation.autoFixCi}
data-testid="automation-auto-fix-ci"
id="automation-auto-fix-ci"
onCheckedChange={(checked) =>
updateChatWorkAutomation(chatId, {
autoFixCi: checked === true,
})
}
/>
<span>Auto-fix CI failures</span>
</label>
<label
className="flex cursor-pointer items-center gap-2"
htmlFor="automation-address-comments"
>
<Checkbox
checked={automation.addressComments}
data-testid="automation-address-comments"
id="automation-address-comments"
onCheckedChange={(checked) =>
updateChatWorkAutomation(chatId, {
addressComments: checked === true,
})
}
/>
<span>Address comments & resolve</span>
</label>
<div className="flex items-center gap-2">
<label
className="flex min-w-0 flex-1 cursor-pointer items-center gap-2"
htmlFor="automation-auto-fix-ci"
>
<Checkbox
checked={automation.autoFixCi}
data-testid="automation-auto-fix-ci"
id="automation-auto-fix-ci"
onCheckedChange={(checked) =>
updateChatWorkAutomation(chatId, {
autoFixCi: checked === true,
})
}
/>
<span>Auto-fix CI failures</span>
</label>
{ciConditionActive && !isTurnActive ? (
<button
className="shrink-0 font-medium text-primary hover:underline"
data-testid="automation-run-ci-now"
onClick={sendCiNudge}
type="button"
>
Run now
</button>
) : null}
</div>
<div className="flex items-center gap-2">
<label
className="flex min-w-0 flex-1 cursor-pointer items-center gap-2"
htmlFor="automation-address-comments"
>
<Checkbox
checked={automation.addressComments}
data-testid="automation-address-comments"
id="automation-address-comments"
onCheckedChange={(checked) =>
updateChatWorkAutomation(chatId, {
addressComments: checked === true,
})
}
/>
<span>Address comments & resolve</span>
</label>
{openThreads > 0 && !isTurnActive ? (
<button
className="shrink-0 font-medium text-primary hover:underline"
data-testid="automation-run-comments-now"
onClick={sendCommentNudge}
type="button"
>
Run now
</button>
) : null}
</div>
</div>
</details>
</div>
@@ -218,6 +218,10 @@ test("first message in a new chat is sent and rendered", async ({ page }) => {
page.getByLabel("Chat messages").getByText("unanswered review comments"),
).toHaveCount(0);
// Manual overrides: open comments expose "Run now"; green CI does not.
await expect(page.getByTestId("automation-run-comments-now")).toBeVisible();
await expect(page.getByTestId("automation-run-ci-now")).toHaveCount(0);
// The header's PR button toggles the panel.
await page.getByTestId("toggle-work-panel").click();
await expect(workPanel).not.toBeVisible();