Files
PaperClipAI/server/src/services/heartbeat-run-runtime-status.ts
T
Devin FoleyandPaperclip c48feee190 Improve live agent feedback during sandboxed runs (#8915)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A core part of that experience is watching active agent runs without
dropping into raw logs first
> - Local and sandbox-backed adapters already record useful run output,
progress, and tool activity
> - But active issue threads could sit visually stale while the agent
was syncing workspaces, tailing sandbox output, or emitting incremental
tool-call updates
> - Operators need timely, human-readable progress while preserving the
raw transcript underneath
> - This pull request streams sandbox run-log progress into runtime
status, keeps visible issue threads refreshed, and folds repeated ACPX
tool updates into stable transcript cards
> - The benefit is that long-running agent work becomes easier to
supervise without changing the task/comment control-plane model

## Linked Issues or Issue Description

No public GitHub issue exists for this exact change.

Problem/motivation:

- During long-running sandboxed agent work, the issue UI can appear idle
even though the agent is actively syncing, running tools, or producing
incremental output.
- Operators need realtime feedback at the issue-thread layer, not only
after opening raw logs or waiting for the final heartbeat result.
- Related public context: #1808 previously added live-run status dots to
Projects; #4362 touches heartbeat wakeup behavior but is not a duplicate
of this runtime/UI feedback change.

## What Changed

- Added sandbox run-log streaming support and defaulted sandbox-capable
local adapters into the richer live-feedback path.
- Surfaced environment/sandbox sync progress through heartbeat runtime
status with bounded, redacted snippets.
- Added live issue-thread cache patching so visible active runs update
as progress events arrive.
- Folded repeated ACPX `tool_call` updates into one transcript card
instead of stacking duplicate cards.
- Updated adapter docs and added focused regression coverage for sandbox
log streaming, runtime status, ACPX parsing, live updates, transcript
rendering, and issue chat messages.

## Verification

- `pnpm install --frozen-lockfile`
- `pnpm exec vitest run ui/src/context/LiveUpdatesProvider.test.ts`
- `pnpm exec vitest run
server/src/services/heartbeat-run-runtime-status.test.ts
server/src/__tests__/heartbeat-runtime-state.test.ts
ui/src/context/LiveUpdatesProvider.test.ts`
- `pnpm exec vitest run
packages/adapter-utils/src/execution-target-sandbox.test.ts
packages/adapter-utils/src/sandbox-managed-runtime.test.ts
server/src/services/heartbeat-run-runtime-status.test.ts
server/src/__tests__/agent-live-run-routes.test.ts
server/src/__tests__/heartbeat-runtime-state.test.ts
packages/adapters/acpx-local/src/ui/parse-stdout.test.ts
ui/src/context/LiveUpdatesProvider.test.ts
ui/src/components/transcript/RunTranscriptView.test.tsx
ui/src/lib/issue-chat-messages.test.ts
ui/src/components/IssueChatThread.test.tsx`
- GitHub PR workflow on head `8397953e7b41ccd42e5d9457ee7e4dfb996e4ec5`:
`verify`, build, typecheck/release-registry, e2e, general shards,
serialized server shards, and canary dry run passed.
- Greptile Review on head `8397953e7b41ccd42e5d9457ee7e4dfb996e4ec5`:
Confidence Score 5/5, no unresolved review threads.

## Risks

- Live issue-thread cache patching could miss an edge case for a route
shape not covered by tests.
- Surfacing active-run snippets needs continued care around redaction;
this PR keeps snippets bounded and adds redaction-focused coverage.
- More frequent active-run UI refreshes could expose performance issues
on very large issue threads, though updates are scoped to visible
run/query caches.

## Model Used

OpenAI GPT-5 via Codex, operating as a tool-enabled coding agent with
shell, git, and repository-editing capabilities. Context window size is
not exposed in this runtime.

## 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 <noreply@paperclip.ing>
2026-07-02 22:21:56 -07:00

184 lines
6.0 KiB
TypeScript

import type { HeartbeatRunStatusPhase } from "@paperclipai/shared";
import { redactSensitiveText } from "../redaction.js";
export const HEARTBEAT_RUN_RUNTIME_STATUS_TTL_MS = 90_000;
export const MAX_HEARTBEAT_RUN_RUNTIME_STATUS_MESSAGE_CHARS = 180;
export const MAX_HEARTBEAT_RUN_RUNTIME_TOOL_NAME_CHARS = 80;
export const MAX_HEARTBEAT_RUN_RUNTIME_ASSISTANT_SNIPPET_CHARS = 220;
export interface HeartbeatRunRuntimeStatus {
companyId: string;
issueId: string | null;
agentId: string;
runId: string;
phase: HeartbeatRunStatusPhase;
message: string;
updatedAt: Date;
currentToolName: string | null;
lastAssistantSnippet: string | null;
lastEventAt: Date | null;
}
const runtimeStatusesByRunId = new Map<string, HeartbeatRunRuntimeStatus>();
function cloneStatus(status: HeartbeatRunRuntimeStatus): HeartbeatRunRuntimeStatus {
return {
...status,
updatedAt: new Date(status.updatedAt),
lastEventAt: status.lastEventAt ? new Date(status.lastEventAt) : null,
};
}
function sanitizeRuntimeStatusText(value: string, maxChars: number): string {
const normalized = value.replace(/\s+/g, " ").trim();
const redacted = redactSensitiveText(normalized);
if (redacted.length <= maxChars) return redacted;
return `${redacted.slice(0, maxChars - 3)}...`;
}
export function sanitizeHeartbeatRunRuntimeStatusMessage(message: string): string {
return sanitizeRuntimeStatusText(message, MAX_HEARTBEAT_RUN_RUNTIME_STATUS_MESSAGE_CHARS);
}
export function sanitizeHeartbeatRunRuntimeToolName(toolName: string): string {
return sanitizeRuntimeStatusText(toolName, MAX_HEARTBEAT_RUN_RUNTIME_TOOL_NAME_CHARS);
}
export function sanitizeHeartbeatRunRuntimeAssistantSnippet(snippet: string): string {
return sanitizeRuntimeStatusText(snippet, MAX_HEARTBEAT_RUN_RUNTIME_ASSISTANT_SNIPPET_CHARS);
}
function isExpired(status: HeartbeatRunRuntimeStatus, now: Date, ttlMs: number) {
return now.getTime() - status.updatedAt.getTime() > ttlMs;
}
export function setHeartbeatRunRuntimeStatus(
input: Omit<
HeartbeatRunRuntimeStatus,
"message" | "updatedAt" | "currentToolName" | "lastAssistantSnippet" | "lastEventAt"
> & {
message: string;
updatedAt?: Date;
currentToolName?: string | null;
lastAssistantSnippet?: string | null;
lastEventAt?: Date | null;
},
): HeartbeatRunRuntimeStatus | null {
const message = sanitizeHeartbeatRunRuntimeStatusMessage(input.message);
if (!message) {
clearHeartbeatRunRuntimeStatus(input.runId);
return null;
}
const status: HeartbeatRunRuntimeStatus = {
companyId: input.companyId,
issueId: input.issueId,
agentId: input.agentId,
runId: input.runId,
phase: input.phase,
message,
updatedAt: input.updatedAt ? new Date(input.updatedAt) : new Date(),
currentToolName: input.currentToolName
? sanitizeHeartbeatRunRuntimeToolName(input.currentToolName)
: null,
lastAssistantSnippet: input.lastAssistantSnippet
? sanitizeHeartbeatRunRuntimeAssistantSnippet(input.lastAssistantSnippet)
: null,
lastEventAt: input.lastEventAt ? new Date(input.lastEventAt) : null,
};
runtimeStatusesByRunId.set(status.runId, status);
return cloneStatus(status);
}
/**
* Refresh the activity timestamps of an existing runtime status without
* discarding its message/tool context, so streamed run-log output keeps the
* "Working... / X ago" line fresh between structured events. When no live
* status exists (first output, or the previous one expired past the TTL), a
* fallback `run_activity` status is created instead.
*/
export function touchHeartbeatRunRuntimeStatus(input: {
companyId: string;
issueId: string | null;
agentId: string;
runId: string;
at?: Date;
fallbackPhase?: HeartbeatRunStatusPhase;
fallbackMessage?: string;
}): HeartbeatRunRuntimeStatus | null {
const at = input.at ?? new Date();
const existing = runtimeStatusesByRunId.get(input.runId);
if (
existing &&
!isExpired(existing, at, HEARTBEAT_RUN_RUNTIME_STATUS_TTL_MS) &&
existing.companyId === input.companyId &&
existing.agentId === input.agentId
) {
if (at.getTime() > existing.updatedAt.getTime()) {
existing.updatedAt = new Date(at);
}
if (!existing.lastEventAt || at.getTime() > existing.lastEventAt.getTime()) {
existing.lastEventAt = new Date(at);
}
return cloneStatus(existing);
}
return setHeartbeatRunRuntimeStatus({
companyId: input.companyId,
issueId: input.issueId,
agentId: input.agentId,
runId: input.runId,
phase: input.fallbackPhase ?? "run_activity",
message: input.fallbackMessage ?? "Receiving agent output",
updatedAt: at,
lastEventAt: at,
});
}
export function getHeartbeatRunRuntimeStatus(
runId: string,
expected?: {
companyId?: string | null;
issueId?: string | null;
agentId?: string | null;
now?: Date;
ttlMs?: number;
},
): HeartbeatRunRuntimeStatus | null {
const status = runtimeStatusesByRunId.get(runId);
if (!status) return null;
const now = expected?.now ?? new Date();
const ttlMs = expected?.ttlMs ?? HEARTBEAT_RUN_RUNTIME_STATUS_TTL_MS;
if (isExpired(status, now, ttlMs)) {
runtimeStatusesByRunId.delete(runId);
return null;
}
if (expected?.companyId !== undefined && status.companyId !== expected.companyId) return null;
if (expected?.issueId !== undefined && status.issueId !== expected.issueId) return null;
if (expected?.agentId !== undefined && status.agentId !== expected.agentId) return null;
return cloneStatus(status);
}
export function clearHeartbeatRunRuntimeStatus(runId: string): boolean {
return runtimeStatusesByRunId.delete(runId);
}
export function clearAllHeartbeatRunRuntimeStatuses(): void {
runtimeStatusesByRunId.clear();
}
export function sweepExpiredHeartbeatRunRuntimeStatuses(
now = new Date(),
ttlMs = HEARTBEAT_RUN_RUNTIME_STATUS_TTL_MS,
): number {
let swept = 0;
for (const [runId, status] of runtimeStatusesByRunId) {
if (!isExpired(status, now, ttlMs)) continue;
runtimeStatusesByRunId.delete(runId);
swept += 1;
}
return swept;
}