fix(ui): keep active runner status current and say Working (#13238)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The task transcript shows a running agent's progress.
> - The active-run query stops polling when the live-run list has data.
> - The transcript still preferred that initial snapshot, so an old
execution-confirmation state could remain after work resumed.
> - This pull request uses the refreshed snapshot for the same run and
keeps active status text at Working.
> - Operators can see current activity without connection-state jargon.

## Linked Issues or Issue Description

**What happened?**

The task transcript said Reconnecting while the runner continued sending
messages and calling tools. The stale projection could also hide the
Thinking tail or stop the status spinner and timer.

**Expected behavior**

The selected run uses its current live snapshot. Active transcripts say
Working and show current activity. Completed and failed runs say Worked
and Stopped.

**Steps to reproduce**

1. Open a running task before its execution confirmation arrives.
2. Let the active-run query stop polling when the live-run list returns
the run.
3. Let the list refresh to working while the cached active-run snapshot
still says reconnecting.
4. Inspect the transcript status and activity tail.

**Paperclip version or commit**

Base commit: 52811c6ce.

**Deployment mode**

Built from source. The report concerns the new runner. The fix also
covers legacy transcript status text.

Searched open issues and open/closed pull requests for runner
reconnection work. No duplicate fix found.

## What Changed

- Refresh the selected active run from the polled list by matching the
task execution-run ID. Reject cached predecessors after run replacement.
- Use Working in native and legacy transcripts and active agent cards.
- Keep the active spinner, timer, and Thinking tail independent of
diagnostic execution phases. Terminal status takes precedence.
- Add stale-snapshot, timer, and terminal-state regressions. Update the
recovery story and documentation.

## Verification

- Focused Vitest suite: 148 tests passed across the run resolver, live
pill, runner turn, and task thread.
- `pnpm check:token-gates`: passed.
- `pnpm -r typecheck`: passed.
- Browser: inspected the recovery Storybook with a reconnecting
projection. It renders Working and Thinking.
- `pnpm build`: passed.
- `pnpm build-storybook`: passed.
- Full local `pnpm test:run` reported failures in unchanged server
tests; stopped the remaining general-server run after the equivalent CI
shards passed. The file-resource suite passes on rerun (35/35). Building
`build:runner-binaries` fixed a missing fake-provider binary; the
native-session-resume suite still has one continuity-reason assertion
mismatch (36/37 pass).
- Ran the remaining local test groups separately: both workspace groups
passed. All serialized suites passed except `pipelines-routes.test.ts`,
which still reports a socket hang-up on rerun (18/19 pass). The initial
access-route timeout passed on rerun. No server or runner source files
differ from the base.
- CI: all test shards, browser E2E, typecheck, runner verification, and
build passed on `22069462f30bdfcfb58f295da901a71ae5a47776`; the
aggregate verification gate passed (31 checks passed, 2 optional
Storybook checks skipped). Greptile is 5/5 with all review threads
resolved.

## Risks

- Low risk. This changes UI snapshot selection and presentation. It does
not change server recovery, leases, retry authority, or permissions.
- The live snapshot must match the task execution-run ID. Missing
matches use the cached active run only when its ID also matches.

## Model Used

OpenAI GPT-6 (Codex). The exact deployment identifier and context-window
size are not exposed in this session. Used reasoning, repository
inspection, code execution, and browser verification.

## 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 (focused/UI and workspace
tests; full-suite limitations documented above)
- [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 <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-11 11:34:45 -05:00 committed by GitHub
parent 4fde92107e
commit 1d26ae965e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 125 additions and 40 deletions

View File

@ -815,7 +815,7 @@ Every continuation carries the triggering request, ordered user direction, inter
Legacy adapters without a verified resume capability use the same automatic no-replay disposition after provider failure. An availability error family (including quota or upstream overload) is not proof that earlier actions did not happen. The compatible adapter result field `executionRecovery: { kind: "bootstrap", providerWorkStarted: false }` can establish a pre-provider retry; the server records the same evidence for failures before adapter dispatch. Bootstrap retries and process-loss bootstrap retries use the same durable counter and delay. Productive max-turn continuation remains a separate execution boundary rather than a failed provider incident. A pre-dispatch wait for a confirmed live workspace holder is also a resource wait, not a provider failure: explicit `workspace_wait` evidence preserves that wait path without consuming the failure incident budget.
The server projection remains available for execution diagnostics. Normal working, finishing, and interaction waits add no badges or cards to task lists or feeds. A retry may briefly change the existing transcript header to Reconnecting; attempts, causes, and recovery decisions belong in the run log. There is no reconciliation dialog. Safe recovery remains automatic. If it cannot continue safely, the source-scoped recovery record resolves with a blocked no-replay disposition and the ordinary task status becomes blocked, preserving its owner. Resolving this record does not grant replay authority: dispatch continues enforcing the durable hold. Replacement history remains inspectable and the composer stays usable.
The server projection remains available for execution diagnostics. Normal working, finishing, and interaction waits add no badges or cards to task lists or feeds. Active transcript headers keep saying Working during automatic retry and execution confirmation; attempts, causes, and recovery decisions belong in the run log. There is no reconciliation dialog. Safe recovery remains automatic. If it cannot continue safely, the source-scoped recovery record resolves with a blocked no-replay disposition and the ordinary task status becomes blocked, preserving its owner. Resolving this record does not grant replay authority: dispatch continues enforcing the durable hold. Replacement history remains inspectable and the composer stays usable.
An operator Stop reaches embedded ACP execution through its run-owned cancellation signal. The response waits for adapter settlement; acknowledgment requires the local provider to have exited. A deadline or failed cleanup never grants continuation permission. A persistent local ACP session can record an interrupted checkpoint only after acknowledged cancellation, complete tool reporting with settled reads (or no tools), and successful cleanup. Writes, shell commands, incomplete client-operation receipts, forced cancellation, and lost transports retain the ordinary no-replay hold. Continuation must restore the same compatible session; an unavailable checkpoint cannot fall back to a new session. A restored provider receives the current run identity, API credential, and scratch environment. Run-owned scratch paths rotate without changing session identity, while user configuration changes still invalidate compatibility.

View File

@ -203,7 +203,7 @@ const AgentRunCard = memo(function AgentRunCard({
<Identity name={run.agentName} size="sm" className="[&>span:last-child]:!text-(length:--text-micro)" />
</div>
<div className="mt-2 flex items-center gap-2 text-(length:--text-micro) text-muted-foreground">
<span>{(run.execution?.phase === "reconnecting" || run.execution?.phase === "retry_scheduled") ? "Reconnecting…" : (isActive ? "Live now" : run.finishedAt ? `Finished ${relativeTime(run.finishedAt)}` : `Started ${relativeTime(run.createdAt)}`)}</span>
<span>{isActive ? "Working" : run.finishedAt ? `Finished ${relativeTime(run.finishedAt)}` : `Started ${relativeTime(run.createdAt)}`}</span>
</div>
</div>

View File

@ -1261,12 +1261,7 @@ function IssueChatChainOfThought({
let headerVerb: string;
let headerSuffix: string | null = null;
if (isActive) {
const execution = custom.execution as { phase?: string } | undefined;
headerVerb =
execution?.phase === "reconnecting" ||
execution?.phase === "retry_scheduled"
? "Reconnecting…"
: "Working";
headerVerb = "Working";
if (liveElapsed) headerSuffix = `for ${liveElapsed}`;
} else if (segmentTiming) {
const durationMs = segmentTiming.endMs - segmentTiming.startMs;

View File

@ -2,7 +2,8 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ExecutionProjection } from "@paperclipai/shared";
import type { TranscriptEntry } from "../../adapters";
import { TaskChatLiveRunPill, toolCountSummaryFromEntries } from "./TaskChatLiveRunPill";
@ -50,6 +51,7 @@ describe("TaskChatLiveRunPill", () => {
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.useRealTimers();
});
it("shimmers 'Working' with elapsed + tool summary while streaming", () => {
@ -71,6 +73,34 @@ describe("TaskChatLiveRunPill", () => {
expect(pill?.textContent).toContain("called 3 tools");
});
it.each(["reconnecting", "retry_scheduled"] as const)(
"keeps Working animated and the timer advancing with a %s projection",
(phase) => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-09-11T12:00:00Z"));
const startedAtMs = Date.now() - 6_000;
const execution = { phase } as ExecutionProjection;
const render = (status: string) => act(() => root.render(
<TaskChatLiveRunPill status={status} execution={execution}
startedAtMs={startedAtMs} finishedAtMs={startedAtMs + 8_000}
toolSummary="called 2 tools" />,
));
render("running");
expect(container.querySelector(".shimmer-text")?.textContent).toBe("Working");
expect(container.querySelector(".animate-spin")).not.toBeNull();
expect(container.textContent).toContain("for 6 seconds");
expect(container.textContent).toContain("called 2 tools");
act(() => vi.advanceTimersByTime(2_000));
expect(container.textContent).toContain("for 8 seconds");
render("succeeded");
expect(container.textContent).toContain("Worked");
expect(container.querySelector(".animate-spin")).toBeNull();
render("failed");
expect(container.textContent).toContain("Stopped");
expect(container.textContent).not.toContain("Reconnecting");
},
);
it("settles to a static 'Worked' summary once the run is terminal", () => {
const startedAtMs = 1_000;
act(() => {

View File

@ -45,7 +45,6 @@ export function toolCountSummaryFromEntries(entries: readonly TranscriptEntry[])
*/
export function TaskChatLiveRunPill({
status,
execution,
startedAtMs,
finishedAtMs,
toolSummary,
@ -58,7 +57,7 @@ export function TaskChatLiveRunPill({
finishedAtMs?: number | null;
toolSummary: string | null;
}) {
const active = !isTerminalRunStatus(status) && (!execution || execution.phase === "working");
const active = !isTerminalRunStatus(status);
// One shared page-wide ticker drives the live elapsed readout, matching the
// default view's `useLiveElapsed`.
useSecondTick(active && startedAtMs != null);
@ -68,9 +67,8 @@ export function TaskChatLiveRunPill({
const elapsed = elapsedMs != null
? formatDurationWords(elapsedMs)
: null;
const reconnecting = execution?.phase === "reconnecting" || execution?.phase === "retry_scheduled";
const failed = ["failed", "timed_out", "cancelled", "interrupted"].includes(status);
const verb = reconnecting ? "Reconnecting…" : (!isTerminalRunStatus(status) ? "Working" : failed ? "Stopped" : "Worked");
const verb = active ? "Working" : failed ? "Stopped" : "Worked";
const suffix = elapsed ? `for ${elapsed}` : null;
return (
@ -83,7 +81,7 @@ export function TaskChatLiveRunPill({
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-muted-foreground" />
) : (
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
<span className={cn("h-1.5 w-1.5 rounded-full", (reconnecting || failed || !isTerminalRunStatus(status)) ? "bg-muted-foreground/40" : "bg-emerald-500/70")} />
<span className={cn("h-1.5 w-1.5 rounded-full", (failed || active) ? "bg-muted-foreground/40" : "bg-emerald-500/70")} />
</span>
)}
{active ? <span className={cn("shimmer-text")}>{verb}</span> : verb}

View File

@ -1,5 +1,6 @@
// @vitest-environment jsdom
import type { ExecutionProjection } from "@paperclipai/shared";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@ -40,6 +41,7 @@ describe("TaskChatRunnerTurn", () => {
) => void,
suppressFinal = false,
continuedAfterSteering = false,
execution?: ExecutionProjection,
) =>
act(() =>
root.render(
@ -50,6 +52,7 @@ describe("TaskChatRunnerTurn", () => {
agentName="Runner"
items={items}
status={status}
execution={execution}
startedAtMs={Date.now() - 2_000}
suppressFinal={suppressFinal}
continuedAfterSteering={continuedAfterSteering}
@ -141,6 +144,26 @@ describe("TaskChatRunnerTurn", () => {
);
});
it.each(["reconnecting", "retry_scheduled"] as const)(
"keeps the active turn and Thinking tail visible with a %s projection",
(phase) => {
const execution = { phase } as ExecutionProjection;
render([], "running", "run-1", undefined, false, false, execution);
expect(container.querySelector('[data-testid="task-chat-turn-status-header"]')?.textContent)
.toContain("Working for");
expect(container.querySelector('[data-testid="task-chat-current-activity-label"]')?.textContent)
.toBe("Thinking");
render([], "succeeded", "run-1", undefined, false, false, execution);
expect(container.querySelector('[data-testid="task-chat-turn-status-header"]')?.textContent)
.toContain("Worked");
expect(container.querySelector('[data-testid="task-chat-current-activity"]')).toBeNull();
render([], "failed", "run-1", undefined, false, false, execution);
expect(container.querySelector('[data-testid="task-chat-turn-status-header"]')?.textContent)
.toContain("Stopped");
expect(container.textContent).not.toContain("Reconnecting");
},
);
it("labels the streaming tail as a continuation after steering", () => {
render([], "running", "run-1", undefined, false, true);

View File

@ -292,13 +292,11 @@ function RunnerActivityMarker({ item }: { item: TaskChatMarkerItem }) {
function RunnerTurnStatus({
status,
execution,
startedAtMs,
finishedAtMs,
continuedAfterSteering = false,
}: {
status: string;
execution?: ExecutionProjection | null;
startedAtMs: number | null;
finishedAtMs?: number | null;
continuedAfterSteering?: boolean;
@ -315,9 +313,8 @@ function RunnerTurnStatus({
const elapsed = formatCompactDuration(elapsedMs);
const failed = terminalStatusFailed(status);
const reconnecting = execution?.phase === "reconnecting" || execution?.phase === "retry_scheduled";
const label = reconnecting ? "Reconnecting…" : (terminal ? (failed ? "Stopped" : "Worked") : "Working");
const semanticLabel = reconnecting ? label : terminal
const label = terminal ? (failed ? "Stopped" : "Worked") : "Working";
const semanticLabel = terminal
? elapsed
? `${label} ${failed ? "after" : "for"} ${elapsed}`
: label
@ -431,7 +428,6 @@ export function TaskChatRunnerTurn({
agentIcon,
items,
status,
execution,
startedAtMs,
finishedAtMs,
activityUnavailable = false,
@ -530,7 +526,6 @@ export function TaskChatRunnerTurn({
) : null}
<RunnerTurnStatus
status={status}
execution={execution}
startedAtMs={startedAtMs}
finishedAtMs={finishedAtMs}
continuedAfterSteering={continuedAfterSteering}
@ -609,7 +604,7 @@ export function TaskChatRunnerTurn({
/>
</div>
) : null}
{!final && (!execution || execution.phase === "working") ? <RunnerCurrentActivityTail items={currentActivityItems} status={status} /> : null}
{!final ? <RunnerCurrentActivityTail items={currentActivityItems} status={status} /> : null}
</div>
);
}

View File

@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { Issue } from "@paperclipai/shared";
import type { ActiveRunForIssue } from "../api/heartbeats";
import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats";
import { resolveIssueActiveRun, shouldTrackIssueActiveRun } from "./issueActiveRun";
describe("issueActiveRun", () => {
@ -12,6 +12,39 @@ describe("issueActiveRun", () => {
...overrides,
});
it("refreshes the selected run from the polled list after startup confirmation", () => {
const issue = makeIssue({ status: "in_progress", executionRunId: "run-1" });
const initialRun = {
id: "run-1",
status: "running",
execution: { phase: "reconnecting", label: "Confirming execution" },
} as ActiveRunForIssue;
const refreshedRun = {
...initialRun,
execution: { phase: "working", label: "Working" },
currentToolName: "Read file",
} as LiveRunForIssue;
const otherRun = { ...refreshedRun, id: "run-2" };
expect(resolveIssueActiveRun(issue, initialRun)).toBe(initialRun);
expect(resolveIssueActiveRun(issue, initialRun, [otherRun, refreshedRun])).toBe(refreshedRun);
expect(resolveIssueActiveRun(issue, initialRun, [otherRun])).toBe(initialRun);
expect(resolveIssueActiveRun(issue, null, [refreshedRun])).toBe(refreshedRun);
expect(resolveIssueActiveRun(makeIssue({ status: "done" }), initialRun, [refreshedRun])).toBeNull();
});
it("selects the task's replacement run instead of the cached predecessor", () => {
const issue = makeIssue({ status: "in_progress", executionRunId: "run-new" });
const oldRun = { id: "run-old", status: "running" } as LiveRunForIssue;
const newRun = { id: "run-new", status: "running" } as LiveRunForIssue;
expect(resolveIssueActiveRun(issue, oldRun, [oldRun, newRun])).toBe(newRun);
expect(resolveIssueActiveRun(issue, oldRun, [oldRun])).toBeNull();
expect(resolveIssueActiveRun(issue, oldRun)).toBeNull();
expect(resolveIssueActiveRun(issue, null, [newRun])).toBe(newRun);
expect(resolveIssueActiveRun(makeIssue({ status: "in_progress" }), oldRun, [oldRun, newRun])).toBe(oldRun);
});
it("tracks active runs while an issue is still in progress", () => {
expect(shouldTrackIssueActiveRun(makeIssue({ status: "in_progress" }))).toBe(true);
});

View File

@ -1,5 +1,5 @@
import type { Issue } from "@paperclipai/shared";
import type { ActiveRunForIssue } from "../api/heartbeats";
import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats";
export function shouldTrackIssueActiveRun(
issue: Pick<Issue, "status" | "executionRunId"> | null | undefined,
@ -10,6 +10,13 @@ export function shouldTrackIssueActiveRun(
export function resolveIssueActiveRun(
issue: Pick<Issue, "status" | "executionRunId"> | null | undefined,
activeRun: ActiveRunForIssue | null | undefined,
liveRuns?: readonly LiveRunForIssue[],
): ActiveRunForIssue | null {
return shouldTrackIssueActiveRun(issue) ? (activeRun ?? null) : null;
if (!shouldTrackIssueActiveRun(issue)) return null;
// The active-run query stops polling while the live-run list is populated.
// Prefer the task's current execution identity when its cached run is stale.
const runId = issue?.executionRunId ?? activeRun?.id;
if (!runId) return null;
return liveRuns?.find((run) => run.id === runId)
?? (activeRun?.id === runId ? activeRun : null);
}

View File

@ -2173,8 +2173,8 @@ export function DesignGuide() {
<Section title="Execution recovery">
<p className="text-sm text-muted-foreground">
Recovery runs in the background. Task lists keep their ordinary status without
execution badges. The transcript may briefly say Reconnecting, then resumes its
normal presentation. Recovery decisions and attempts belong in the run log;
execution badges. Active transcript headers keep saying Working during automatic
recovery. Recovery decisions and attempts belong in the run log;
there is no execution status card or reconciliation form.
</p>
</Section>

View File

@ -1462,8 +1462,8 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
});
const resolvedActiveRun = useMemo(
() =>
resolveIssueActiveRun({ status: issueStatus, executionRunId }, activeRun),
[activeRun, executionRunId, issueStatus],
resolveIssueActiveRun({ status: issueStatus, executionRunId }, activeRun, liveRuns),
[activeRun, executionRunId, issueStatus, liveRuns],
);
const assigneeUsesPaperclipRunner = Boolean(
issueAssigneeAgentId &&

View File

@ -81,11 +81,15 @@ const state = (overrides: Partial<ExecutionProjection>) => ({
args: { execution: { ...base, ...overrides } },
});
export const Working: Story = state({});
export const Reconnecting: Story = state({
phase: "reconnecting",
label: "Reconnecting",
attempt: 2,
});
export const Reconnecting: Story = {
...state({ phase: "reconnecting", label: "Reconnecting", attempt: 2 }),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByTestId("task-chat-turn-status-header")).toHaveTextContent("Working");
await expect(canvas.getByTestId("task-chat-current-activity-label")).toHaveTextContent("Thinking");
await expect(canvas.queryByText(/Reconnecting/)).not.toBeInTheDocument();
},
};
export const RetryScheduled: Story = state({
phase: "retry_scheduled",
label: "Retry scheduled",
@ -262,8 +266,8 @@ export const NativeChatStatusLabels: Story = {
Native-runner chat status labels
</h2>
<p className="text-sm text-muted-foreground">
The existing transcript header stays quiet. Only an intermediate
reconnection briefly changes its text.
Active transcript headers keep saying Working during automatic recovery.
Current activity remains visible while the run continues.
</p>
{labelExamples.map((execution) => (
<section key={execution.phase} className="rounded-lg border p-3">
@ -286,8 +290,8 @@ export const LegacyChatStatusLabels: Story = {
<div className="space-y-4">
<h2 className="text-lg font-semibold">Legacy chat status labels</h2>
<p className="text-sm text-muted-foreground">
Normal transcript presentation, with a brief neutral reconnection
indicator and no recovery panel.
Active transcripts keep the Working indicator and elapsed timer during
automatic recovery, with no recovery panel.
</p>
{labelExamples.map((execution) => (
<section key={execution.phase} className="rounded-lg border p-3">
@ -350,8 +354,8 @@ function DashboardLabelExamples() {
<div className="space-y-4">
<h2 className="text-lg font-semibold">Dashboard agent-card labels</h2>
<p className="text-sm text-muted-foreground">
The existing dashboard layout stays unchanged. Reconnection is a brief
update to the existing line, with no additional card or controls.
Active agents say Working during automatic recovery, with no additional
card or controls.
</p>
<ActiveAgentsPanel
companyId="company-storybook"