Add Claude workflow stale resume actions

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-05-30 02:48:00 -07:00
co-authored by Orca
parent b8b1ad0b55
commit 90ec101146
24 changed files with 1479 additions and 11 deletions
@@ -0,0 +1,149 @@
# Claude Workflow Stale Resume Actions
## Problem
Claude dynamic workflows can be interrupted, abandoned, or left stale while the parent Claude pane looks merely idle. Orca already decays stale working agent rows after `AGENT_STATUS_STALE_AFTER_MS` (`src/shared/agent-status-types.ts`), with the dashboard deriving the same decay in `buildAgentRowsForWorktree` (`src/renderer/src/components/dashboard/useDashboardData.ts`) and sidebar rows deriving it in `buildWorktreeAgentRows` (`src/renderer/src/components/sidebar/useWorktreeAgentRows.ts`). That tells the user something stopped updating, but the current Claude hook/status path only preserves generic state, prompt, tool preview, assistant message, and interrupt fields; it does not expose workflow-specific recovery data such as `scriptPath` and `resumeFromRunId`.
## Goal
Add a compact recovery surface on existing Claude agent rows: stale/resumable workflow summaries should show a clear stale hint and offer safe actions to copy resume instructions, reveal the generated JS script, reveal transcript files, and jump back to the parent Claude pane. The primary outcome is confidence and recoverability, not automatic execution.
## Non-goals
- Do not auto-send the resume command into a Claude terminal.
- Do not mutate Claude workflow files.
- Do not add workflow status grouping, a workflow detail panel, or a new row type beyond the minimal metadata/action surface needed by existing agent rows.
- Do not depend on GitHub-only or local-only paths.
- Do not expose a generic renderer-controlled "open this path" action for workflow files.
## Design
1. Add a small workflow metadata model.
- Use `src/shared/claude-workflow-actions.ts` for action-safe types; project-owned declarations stay in `.ts`, not `.d.ts`.
- Add an optional workflow recovery sidecar to `AgentHookEventPayload` / the enriched main cache, `AgentStatusIpcPayload`, and `AgentStatusEntry` so `agentStatus:set` and `agentStatus:getSnapshot` carry the same shape through `useIpcEvents`.
- Do not rely on adding unknown fields to `AgentStatusPayload` / `ParsedAgentStatusPayload` alone. `normalizeAgentStatusPayload` intentionally reconstructs a known-field object, and `useIpcEvents` currently re-normalizes only status fields before calling `setAgentStatus`; workflow recovery must be copied explicitly as a sidecar into the store.
- The renderer-facing shape should contain an opaque `workflowId`, `parentPaneKey`, `worktreeId`, `updatedAt`, stale/resumable booleans, and per-action availability/disabled reason.
- Keep raw `scriptPath` and `transcriptDir` in a main-process lookup keyed by `workflowId`. Do not require the renderer to pass paths back for reveal actions.
- Preserve source identity: local entries have `connectionId: null`; SSH entries carry the remote `connectionId` and disable local reveal actions unless a real remote reveal API exists.
2. Detect stale workflow runs.
- Add a Claude workflow response extractor alongside the current Claude hook normalization. Today `extractClaudeToolFields` only reads generic `tool_name`, `tool_input`, `tool_response`, `last_assistant_message`, and `transcript_path` data; workflow recovery fields must be parsed before the generic status normalizer truncates/drops unknown data.
- Attach recovery metadata to the parent Claude agent row; do not create a separate workflow group.
- Mark a workflow stale when it has active workflow child work and neither workflow file mtime nor hook `receivedAt` has advanced for `AGENT_STATUS_STALE_AFTER_MS`.
- Do not change the generic agent state contract. The row may still decay to `idle`; workflow staleness is a separate `workflowRecovery.isStale` hint.
- Mark `resumable` when both `scriptPath` and `resumeFromRunId` are present. Copy can be available for SSH if the remote path text is known; local reveal remains disabled for SSH paths.
3. Add safe action APIs.
- Extend the preload `agentStatus` API, not a separate top-level namespace: `copyWorkflowResumeCommand(workflowId)`, `revealWorkflowScript(workflowId)`, and `revealWorkflowTranscripts(workflowId)`.
- Main owns command construction, clipboard write, path lookup, path normalization, existence/type checks, and Electron `shell.showItemInFolder` calls. Do not route workflow reveal through the existing loose `shell:openPath` bridge.
- Validate every action at click time and return a discriminated result such as `{ ok: true } | { ok: false; reason: 'missing-path' | 'remote-path' | 'not-found' | 'wrong-kind' | 'stale-id' | 'clipboard-failed' | 'launch-failed' }`.
- For local paths, require absolute paths, use Node `path` utilities (`normalize`, `isAbsolute`) instead of string separators, and verify file vs directory with `fs.stat`.
- "Open parent Claude pane" is renderer navigation, not main IPC: parse `parentPaneKey`, verify the tab still exists in the row's `worktreeId`, then route through `activateAndRevealWorktree` and `activateTabAndFocusPane`. If validation fails, toast and do not guess a terminal.
4. Add row-level action affordances.
- Add an icon-only shadcn `DropdownMenu` trigger only when a `DashboardAgentRow` has workflow recovery metadata; normal agent rows must not gain the trigger.
- Menu items: Copy resume command, Reveal workflow script, Reveal transcripts, Open parent Claude pane.
- Disabled items carry concise visible menu text; tooltips only name icon-only triggers.
- Stale badge/copy should be muted and consistent with existing state labels, not a bright warning banner.
- Use shadcn primitives, lucide icons, token classes from `main.css`, and the existing `DashboardAgentRow` layout so the sidebar and dashboard stay aligned.
5. Generate resume text conservatively.
- Copy human-readable paste text, not a shell command:
```text
Resume this Claude Code workflow:
scriptPath: <path>
resumeFromRunId: <runId>
```
- Do not promise a shell command if Claude Code requires invoking the built-in `Workflow` tool from the TUI.
- Include script path and run id in separate lines so the user can paste them into Claude and edit safely.
6. Add pruning and retention.
- Workflow metadata follows the existing live/retained agent row lifecycle; do not add a second completed-workflow list.
- Drop renderer metadata when `dropAgentStatus`, `dismissRetainedAgent`, `dismissRetainedAgentsByWorktree`, or worktree removal drops the owning row.
- Drop main-process path lookup entries when the owning pane/worktree is dismissed or pruned, and keep only a short retained window for clean `done` rows that already qualify for retained agent display.
- Do not assume every renderer pruning path already notifies main. `dropAgentStatus` and `dismissRetainedAgentsByWorktree` fan out `agentStatus:drop`, but worktree-level/prune paths can remove renderer rows without an existing main lookup callback; add explicit cleanup or a bounded main-side TTL for workflow lookup entries.
## Data flow
```text
Claude workflow response extractor
-> main workflow recovery lookup + AgentStatusIpcPayload metadata
-> useIpcEvents normalizes status fields and copies workflow sidecar into AgentStatusEntry
-> existing dashboard/sidebar row builders
-> row dropdown action
-> agentStatus action IPC for copy/reveal, renderer navigation for parent focus
-> typed result
-> toast
```
## Edge cases
- Workflow has `resumeFromRunId` but no `scriptPath`.
- Script was deleted by the user before clicking reveal/copy.
- Parent pane is gone but script/transcripts remain.
- Remote SSH workflow paths are not local Finder/Explorer paths.
- Claude workflow file mtimes update while the user has the menu open.
- Clipboard write fails due OS permission.
- Multiple resumed runs share one script path.
- Main-process workflow id was pruned between menu render and click.
- Malformed, relative, URI, or Windows/Posix-opposite paths arrive from Claude metadata.
- Snapshot hydration delivers older metadata after a newer live update.
## Test plan
- Unit: stale derivation from workflow update time, hook `receivedAt`, child-active signal, and threshold.
- Unit: resume copy text includes script path/run id on separate lines and handles missing fields.
- Unit: action resolver returns typed errors for missing path, remote path, stale id, not found, wrong kind, malformed path, and clipboard failure.
- Unit: Windows and POSIX path validation use `path` utilities and do not split on `/` or `\`.
- Hook/server: workflow sidecar survives Claude hook parsing, `setListener` fanout, status snapshot hydration, and remote `connectionId` stamping without passing through generic status-field normalization.
- IPC/preload: `agentStatus` API exposes the new methods in `api-types.ts`, `index.ts`, and the web preload fallback stubs.
- Renderer store: snapshot/update handling preserves workflow metadata without regressing older payloads that omit it, including older snapshot entries whose timestamps lose to newer live updates.
- Component: workflow row menu renders enabled/disabled items correctly and does not affect normal agent rows.
- Component/store: dismissing a workflow clears retained action metadata only for that workflow.
- Electron: stale row menu, copy action toast, disabled remote reveal state, parent-pane focus smoke, normal-row smoke.
## UI quality bar
- The stale state must be noticeable but not alarming: state dot plus compact label, not a modal or banner.
- Actions should be discoverable from the workflow row without bloating every normal agent row.
- Copy/reveal/open results should use short toasts; no persistent panels.
- Disabled remote/missing-path states must explain the limitation in one line.
- The row height must stay stable when the action trigger appears on hover/focus.
- Keyboard users must be able to open the menu and activate each enabled item; the outer row remains a plain clickable surface with real buttons inside.
## Review screenshots
1. Stale resumable workflow row with action menu open.
2. Disabled remote/missing-path menu state.
3. Copy resume command success toast.
4. Completed retained workflow row with reveal actions available.
5. Normal agent row smoke with no workflow action trigger.
Do not commit evidence images; attach them to the PR conversation.
## Rollout
1. Add shared workflow recovery metadata and stale/resume derivation tests.
2. Add main action lookup, IPC handlers, preload API, and typed result tests.
3. Thread metadata through renderer status snapshot/update handling and row builders.
4. Add row menu UI, parent-pane navigation, and toasts.
5. Validate in Electron with screenshots.
## Lightweight Eng Review
- Scope: Reduced to user-initiated recovery actions on existing agent rows. No automatic resume injection, because PTY keystroke injection into Claude can resume the wrong turn if focus or prompt state is stale.
- Architecture/data flow: Main owns filesystem/clipboard/action validation and stores raw paths; renderer renders available actions, calls typed IPC methods, and performs only existing pane navigation. This keeps SSH/local differences behind runtime checks.
- Failure modes covered:
- Missing script/run id disables copy or returns a typed error.
- Remote paths do not call local reveal APIs.
- Parent pane focus is best-effort and never guesses a new terminal.
- Clipboard/reveal failures surface as toasts.
- Stale threshold updates when workflow mtimes change.
- Test coverage required:
- Pure stale/resume command tests.
- IPC handler tests for local, remote, missing file, malformed path, stale id.
- Component tests for menu visibility and disabled states.
- Electron screenshots for stale, menu, toast, disabled remote, and normal row smoke.
- Performance/blast radius: No extra filesystem crawling beyond the workflow index. Action metadata is summary-sized. Menus mount per visible row only.
- UI quality bar: Existing list-row density, icon-only trigger with tooltip, shadcn dropdown, no noisy badges or card nesting.
- Residual risks: Claude's actual resume UX is a tool call, not a stable shell command. Copy text must be explicit enough for Claude Code users without implying Orca can resume automatically.
@@ -0,0 +1,129 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import {
_claudeWorkflowActionRegistryInternals,
clearClaudeWorkflowRecoveryLookups,
copyClaudeWorkflowResumeCommand,
revealClaudeWorkflowScript,
revealClaudeWorkflowTranscripts
} from './claude-workflow-action-registry'
function register(
overrides: Partial<Parameters<typeof _claudeWorkflowActionRegistryInternals.register>[0]> = {}
) {
return _claudeWorkflowActionRegistryInternals.register(
{
workflowId: 'wf-1',
parentPaneKey: 'tab-1:11111111-1111-4111-8111-111111111111',
scriptPath: '/tmp/workflow.js',
transcriptDir: '/tmp/transcripts',
resumeFromRunId: 'run-1',
hasActiveChildWork: true,
...overrides
},
{ connectionId: null, receivedAt: Date.now() }
)
}
afterEach(() => {
clearClaudeWorkflowRecoveryLookups()
vi.restoreAllMocks()
})
describe('Claude workflow action registry', () => {
it('copies resume text after validating a local script file', () => {
const dir = mkdtempSync(join(tmpdir(), 'orca-workflow-'))
try {
const scriptPath = join(dir, 'workflow.js')
writeFileSync(scriptPath, 'console.log("workflow")')
register({ scriptPath })
const writeText = vi.fn()
const result = copyClaudeWorkflowResumeCommand('wf-1', writeText)
expect(result).toEqual({ ok: true })
expect(writeText).toHaveBeenCalledWith(
[
'Resume this Claude Code workflow:',
`scriptPath: ${scriptPath}`,
'resumeFromRunId: run-1'
].join('\n')
)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('returns typed failures for missing, malformed, and wrong-kind script paths', () => {
register({ scriptPath: undefined })
expect(copyClaudeWorkflowResumeCommand('wf-1', vi.fn())).toEqual({
ok: false,
reason: 'missing-path'
})
clearClaudeWorkflowRecoveryLookups()
register({ scriptPath: 'file:///tmp/workflow.js' })
expect(revealClaudeWorkflowScript('wf-1', vi.fn())).toEqual({
ok: false,
reason: 'invalid-path'
})
const dir = mkdtempSync(join(tmpdir(), 'orca-workflow-'))
try {
clearClaudeWorkflowRecoveryLookups()
register({ scriptPath: dir })
expect(revealClaudeWorkflowScript('wf-1', vi.fn())).toEqual({
ok: false,
reason: 'wrong-kind'
})
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('blocks local reveal for remote workflow paths but still copies known remote text', () => {
_claudeWorkflowActionRegistryInternals.register(
{
workflowId: 'wf-remote',
parentPaneKey: 'tab-1:11111111-1111-4111-8111-111111111111',
scriptPath: '/remote/workflow.js',
resumeFromRunId: 'run-remote',
hasActiveChildWork: true
},
{ connectionId: 'conn-1', receivedAt: Date.now() }
)
const writeText = vi.fn()
expect(revealClaudeWorkflowScript('wf-remote', vi.fn())).toEqual({
ok: false,
reason: 'remote-path'
})
expect(copyClaudeWorkflowResumeCommand('wf-remote', writeText)).toEqual({ ok: true })
expect(writeText).toHaveBeenCalledWith(
expect.stringContaining('scriptPath: /remote/workflow.js')
)
})
it('reveals transcript directories and reports stale ids', () => {
expect(revealClaudeWorkflowTranscripts('missing', vi.fn())).toEqual({
ok: false,
reason: 'stale-id'
})
const dir = mkdtempSync(join(tmpdir(), 'orca-workflow-'))
try {
const transcripts = join(dir, 'transcripts')
mkdirSync(transcripts)
register({ transcriptDir: transcripts })
const show = vi.fn()
expect(revealClaudeWorkflowTranscripts('wf-1', show)).toEqual({ ok: true })
expect(show).toHaveBeenCalledWith(transcripts)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
})
@@ -0,0 +1,211 @@
import { dirname, isAbsolute, normalize } from 'path'
import { statSync } from 'fs'
import {
buildClaudeWorkflowResumeText,
deriveClaudeWorkflowRecoveryMetadata,
type ClaudeWorkflowRecoveryActionResult,
type ClaudeWorkflowRecoveryHookMetadata,
type ClaudeWorkflowRecoveryLookupEntry,
type ClaudeWorkflowRecoveryMetadata
} from '../../shared/claude-workflow-actions'
const WORKFLOW_LOOKUP_TTL_MS = 24 * 60 * 60 * 1000
const lookupByWorkflowId = new Map<string, ClaudeWorkflowRecoveryLookupEntry>()
type LocalPathKind = 'file' | 'directory'
type LocalPathValidation =
| { ok: true; path: string }
| Extract<ClaudeWorkflowRecoveryActionResult, { ok: false }>
function pruneExpiredWorkflowLookups(now = Date.now()): void {
for (const [workflowId, entry] of lookupByWorkflowId) {
if (now - entry.receivedAt > WORKFLOW_LOOKUP_TTL_MS) {
lookupByWorkflowId.delete(workflowId)
}
}
}
function getLookupEntry(workflowId: unknown): ClaudeWorkflowRecoveryLookupEntry | null {
if (typeof workflowId !== 'string' || workflowId.trim().length === 0) {
return null
}
pruneExpiredWorkflowLookups()
return lookupByWorkflowId.get(workflowId) ?? null
}
function normalizeLocalPath(rawPath: string | undefined): string | null {
if (typeof rawPath !== 'string') {
return null
}
const trimmed = rawPath.trim()
if (trimmed.length === 0) {
return null
}
// Why: Electron reveal APIs operate on local filesystem paths only. Reject
// URI-shaped metadata up front so a malformed Claude payload cannot be
// interpreted by the host shell as something broader than a path.
if (/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(trimmed)) {
return null
}
const normalized = normalize(trimmed)
return isAbsolute(normalized) ? normalized : null
}
function validateLocalPath(
rawPath: string | undefined,
expectedKind: LocalPathKind
): LocalPathValidation {
const path = normalizeLocalPath(rawPath)
if (!path) {
return { ok: false, reason: rawPath ? 'invalid-path' : 'missing-path' }
}
let stats
try {
stats = statSync(path)
} catch {
return { ok: false, reason: 'not-found' }
}
const kindMatches = expectedKind === 'file' ? stats.isFile() : stats.isDirectory()
if (!kindMatches) {
return { ok: false, reason: 'wrong-kind' }
}
return { ok: true, path }
}
function getWorkflowUpdatedAt(entry: ClaudeWorkflowRecoveryLookupEntry): number {
const scriptPath = normalizeLocalPath(entry.scriptPath)
if (entry.connectionId !== null || !scriptPath) {
return entry.updatedAt ?? entry.receivedAt
}
try {
const stats = statSync(scriptPath)
return Math.max(entry.updatedAt ?? 0, stats.mtimeMs, entry.receivedAt)
} catch {
return entry.updatedAt ?? entry.receivedAt
}
}
function getTranscriptDirectory(entry: ClaudeWorkflowRecoveryLookupEntry): string | undefined {
if (entry.transcriptDir) {
return entry.transcriptDir
}
const transcriptPath = normalizeLocalPath(entry.transcriptPath)
return transcriptPath ? dirname(transcriptPath) : undefined
}
export function registerClaudeWorkflowRecoveryLookup(
metadata: ClaudeWorkflowRecoveryHookMetadata,
args: { connectionId: string | null; receivedAt: number }
): ClaudeWorkflowRecoveryMetadata {
const entry: ClaudeWorkflowRecoveryLookupEntry = {
...metadata,
connectionId: args.connectionId,
receivedAt: args.receivedAt
}
lookupByWorkflowId.set(metadata.workflowId, entry)
pruneExpiredWorkflowLookups(args.receivedAt)
return deriveClaudeWorkflowRecoveryMetadata(
{
...entry,
transcriptDir: getTranscriptDirectory(entry),
workflowUpdatedAt: getWorkflowUpdatedAt(entry)
},
args.receivedAt
)
}
export function dropClaudeWorkflowRecoveryLookupsForPane(paneKey: string): void {
for (const [workflowId, entry] of lookupByWorkflowId) {
if (entry.parentPaneKey === paneKey) {
lookupByWorkflowId.delete(workflowId)
}
}
}
export function clearClaudeWorkflowRecoveryLookups(): void {
lookupByWorkflowId.clear()
}
export function copyClaudeWorkflowResumeCommand(
workflowId: unknown,
writeText: (text: string) => void
): ClaudeWorkflowRecoveryActionResult {
const entry = getLookupEntry(workflowId)
if (!entry) {
return { ok: false, reason: 'stale-id' }
}
const scriptPath = entry.scriptPath?.trim()
const resumeFromRunId = entry.resumeFromRunId?.trim()
if (!scriptPath) {
return { ok: false, reason: 'missing-path' }
}
if (!resumeFromRunId) {
return { ok: false, reason: 'missing-run-id' }
}
if (entry.connectionId === null) {
const validated = validateLocalPath(scriptPath, 'file')
if (!validated.ok) {
return validated
}
}
try {
writeText(buildClaudeWorkflowResumeText({ scriptPath, resumeFromRunId }))
return { ok: true }
} catch {
return { ok: false, reason: 'clipboard-failed' }
}
}
export function revealClaudeWorkflowScript(
workflowId: unknown,
showItemInFolder: (path: string) => void
): ClaudeWorkflowRecoveryActionResult {
const entry = getLookupEntry(workflowId)
if (!entry) {
return { ok: false, reason: 'stale-id' }
}
if (entry.connectionId !== null) {
return { ok: false, reason: 'remote-path' }
}
const validated = validateLocalPath(entry.scriptPath, 'file')
if (!validated.ok) {
return validated
}
try {
showItemInFolder(validated.path)
return { ok: true }
} catch {
return { ok: false, reason: 'launch-failed' }
}
}
export function revealClaudeWorkflowTranscripts(
workflowId: unknown,
showItemInFolder: (path: string) => void
): ClaudeWorkflowRecoveryActionResult {
const entry = getLookupEntry(workflowId)
if (!entry) {
return { ok: false, reason: 'stale-id' }
}
if (entry.connectionId !== null) {
return { ok: false, reason: 'remote-path' }
}
const validated = validateLocalPath(getTranscriptDirectory(entry), 'directory')
if (!validated.ok) {
return validated
}
try {
showItemInFolder(validated.path)
return { ok: true }
} catch {
return { ok: false, reason: 'launch-failed' }
}
}
export const _claudeWorkflowActionRegistryInternals = {
clear: clearClaudeWorkflowRecoveryLookups,
size: (): number => lookupByWorkflowId.size,
register: registerClaudeWorkflowRecoveryLookup
}
+74
View File
@@ -76,6 +76,80 @@ afterEach(() => {
})
describe('AgentHookServer listener replay', () => {
it('extracts Claude workflow recovery sidecar before generic status normalization', () => {
const normalized = _internals.normalizeHookPayload(
'claude',
buildBody({
hook_event_name: 'PostToolUse',
tool_name: 'Workflow',
tool_response: {
scriptPath: '/tmp/workflow.js',
resumeFromRunId: 'run-1',
transcriptDir: '/tmp/transcripts'
}
}),
'production'
)
expect(normalized).toEqual(
expect.objectContaining({
paneKey: PANE,
workflowRecoveryLookup: expect.objectContaining({
parentPaneKey: PANE,
scriptPath: '/tmp/workflow.js',
resumeFromRunId: 'run-1',
transcriptDir: '/tmp/transcripts',
hasActiveChildWork: true
}),
payload: expect.objectContaining({
state: 'working',
agentType: 'claude'
})
})
)
})
it('stamps remote workflow recovery metadata without enabling local reveal actions', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
try {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'working', prompt: 'p', agentType: 'claude' },
workflowRecoveryLookup: {
workflowId: 'wf-remote',
parentPaneKey: PANE,
worktreeId: 'wt-1',
scriptPath: '/remote/workflow.js',
resumeFromRunId: 'run-remote',
hasActiveChildWork: true
}
},
'conn-1'
)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
workflowRecovery: expect.objectContaining({
workflowId: 'wf-remote',
worktreeId: 'wt-1',
isResumable: true,
actions: expect.objectContaining({
copyResumeCommand: { available: true },
revealScript: { available: false, disabledReason: 'remote-path' }
})
})
})
])
} finally {
vi.useRealTimers()
}
})
it('applies inferred interrupts through the cached status lifecycle', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
+67 -2
View File
@@ -44,6 +44,15 @@ import {
type AgentStatusState,
normalizeAgentStatusPayload
} from '../../shared/agent-status-types'
import type {
ClaudeWorkflowRecoveryHookMetadata,
ClaudeWorkflowRecoveryMetadata
} from '../../shared/claude-workflow-actions'
import {
clearClaudeWorkflowRecoveryLookups,
dropClaudeWorkflowRecoveryLookupsForPane,
registerClaudeWorkflowRecoveryLookup
} from './claude-workflow-action-registry'
import {
isAgentInterruptInputIntent,
type AgentInterruptInferenceRequest
@@ -64,6 +73,7 @@ export type { AgentHookSource }
type EnrichedAgentHookEventPayload = AgentHookEventPayload & {
receivedAt: number
stateStartedAt: number
workflowRecovery?: ClaudeWorkflowRecoveryMetadata
}
export type AgentHookStatusChangeEntry = {
@@ -206,6 +216,13 @@ function sanitizeHydratedEntry(
if (!payload) {
return null
}
const workflowRecoveryLookup = sanitizeWorkflowRecoveryLookup(record.workflowRecoveryLookup)
const workflowRecovery = workflowRecoveryLookup
? registerClaudeWorkflowRecoveryLookup(workflowRecoveryLookup, {
connectionId,
receivedAt
})
: undefined
return {
paneKey,
tabId: typeof tabId === 'string' ? tabId : undefined,
@@ -216,12 +233,46 @@ function sanitizeHydratedEntry(
toolUseId: typeof record.toolUseId === 'string' ? record.toolUseId : undefined,
toolAgentId: typeof record.toolAgentId === 'string' ? record.toolAgentId : undefined,
toolAgentType: typeof record.toolAgentType === 'string' ? record.toolAgentType : undefined,
workflowRecoveryLookup,
workflowRecovery,
payload,
receivedAt,
stateStartedAt
}
}
function readHydratedString(record: Record<string, unknown>, key: string): string | undefined {
const value = record[key]
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined
}
function sanitizeWorkflowRecoveryLookup(
raw: unknown
): ClaudeWorkflowRecoveryHookMetadata | undefined {
if (typeof raw !== 'object' || raw === null) {
return undefined
}
const record = raw as Record<string, unknown>
const workflowId = readHydratedString(record, 'workflowId')
const parentPaneKey = readHydratedString(record, 'parentPaneKey')
const hasActiveChildWork = record.hasActiveChildWork
if (!workflowId || !parentPaneKey || typeof hasActiveChildWork !== 'boolean') {
return undefined
}
const updatedAt = record.updatedAt
return {
workflowId,
parentPaneKey,
worktreeId: readHydratedString(record, 'worktreeId'),
scriptPath: readHydratedString(record, 'scriptPath'),
transcriptDir: readHydratedString(record, 'transcriptDir'),
transcriptPath: readHydratedString(record, 'transcriptPath'),
resumeFromRunId: readHydratedString(record, 'resumeFromRunId'),
hasActiveChildWork,
updatedAt: typeof updatedAt === 'number' && Number.isFinite(updatedAt) ? updatedAt : undefined
}
}
function toAgentStatusIpcPayload(entry: EnrichedAgentHookEventPayload): AgentStatusIpcPayload {
return {
paneKey: entry.paneKey,
@@ -230,7 +281,8 @@ function toAgentStatusIpcPayload(entry: EnrichedAgentHookEventPayload): AgentSta
connectionId: entry.connectionId,
receivedAt: entry.receivedAt,
stateStartedAt: entry.stateStartedAt,
...entry.payload
...entry.payload,
workflowRecovery: entry.workflowRecovery
}
}
@@ -546,10 +598,17 @@ export class AgentHookServer {
| undefined
const stateStartedAt =
previous && previous.payload.state === payload.payload.state ? previous.stateStartedAt : now
const workflowRecovery = payload.workflowRecoveryLookup
? registerClaudeWorkflowRecoveryLookup(payload.workflowRecoveryLookup, {
connectionId: payload.connectionId,
receivedAt: now
})
: previous?.workflowRecovery
return {
...payload,
receivedAt: now,
stateStartedAt
stateStartedAt,
workflowRecovery
}
}
@@ -808,6 +867,7 @@ export class AgentHookServer {
// this PTY is later proven dead before ptyPaneKey is rebuilt, alias
// cleanup is the only path that can evict that retained status.
clearPaneCacheState(this.state, entry.stablePaneKey)
dropClaudeWorkflowRecoveryLookupsForPane(entry.stablePaneKey)
this.runtimeObservedStatusPaneKeys.delete(entry.stablePaneKey)
this.promptSentDedupeByPaneKey.delete(entry.stablePaneKey)
}
@@ -865,6 +925,7 @@ export class AgentHookServer {
toolUseId?: string
toolAgentId?: string
toolAgentType?: string
workflowRecoveryLookup?: unknown
isReplay?: boolean
payload: unknown
},
@@ -968,6 +1029,7 @@ export class AgentHookServer {
toolUseId,
toolAgentId,
toolAgentType,
workflowRecoveryLookup: sanitizeWorkflowRecoveryLookup(envelope.workflowRecoveryLookup),
isReplay: envelope.isReplay === true ? true : undefined,
payload: normalizedPayload
}
@@ -1107,6 +1169,7 @@ export class AgentHookServer {
this.runtimeObservedStatusPaneKeys.clear()
this.promptSentDedupeByPaneKey.clear()
this.legacyPaneKeyAliases.clear()
clearClaudeWorkflowRecoveryLookups()
clearAllListenerCaches(this.state)
this.notifyStatusChangeListeners()
}
@@ -1125,6 +1188,7 @@ export class AgentHookServer {
}
const existing = this.state.lastStatusByPaneKey.get(resolvedPaneKey)
this.state.lastStatusByPaneKey.delete(resolvedPaneKey)
dropClaudeWorkflowRecoveryLookupsForPane(resolvedPaneKey)
this.clearAssistantMessageRetry(resolvedPaneKey)
this.runtimeObservedStatusPaneKeys.delete(resolvedPaneKey)
if (existing?.payload.state === 'done') {
@@ -1143,6 +1207,7 @@ export class AgentHookServer {
const hadStatus = this.state.lastStatusByPaneKey.has(resolvedPaneKey)
this.clearAssistantMessageRetry(resolvedPaneKey)
clearPaneCacheState(this.state, resolvedPaneKey)
dropClaudeWorkflowRecoveryLookupsForPane(resolvedPaneKey)
this.promptSentDedupeByPaneKey.delete(resolvedPaneKey)
let clearedAlias = false
for (const [legacyPaneKey, stablePaneKey] of this.legacyPaneKeyAliases) {
+2
View File
@@ -539,6 +539,7 @@ function openMainWindow(): BrowserWindow {
payload,
receivedAt,
stateStartedAt,
workflowRecovery,
isReplay
}) => {
if (mainWindow?.isDestroyed()) {
@@ -554,6 +555,7 @@ function openMainWindow(): BrowserWindow {
connectionId,
receivedAt,
stateStartedAt,
...(workflowRecovery ? { workflowRecovery } : {}),
...(orchestration ? { orchestration } : {})
})
recordCrashBreadcrumb('agent_state_changed', {
+8
View File
@@ -14,9 +14,14 @@ const onHandlers = new Map<string, (event: unknown, ...args: unknown[]) => void>
const handleHandlers = new Map<string, (event: unknown, ...args: unknown[]) => unknown>()
const removeHandler = vi.fn()
const removeAllListeners = vi.fn()
const clipboardWriteText = vi.fn()
const showItemInFolder = vi.fn()
const PANE_KEY = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111')
vi.mock('electron', () => ({
clipboard: {
writeText: clipboardWriteText
},
ipcMain: {
handle: (channel: string, handler: (event: unknown, ...args: unknown[]) => unknown) => {
handleHandlers.set(channel, handler)
@@ -26,6 +31,9 @@ vi.mock('electron', () => ({
},
removeHandler,
removeAllListeners
},
shell: {
showItemInFolder
}
}))
+25 -1
View File
@@ -1,4 +1,4 @@
import { ipcMain } from 'electron'
import { clipboard, ipcMain, shell } from 'electron'
import type { AgentHookInstallStatus } from '../../shared/agent-hook-types'
import type {
AgentStatusIpcPayload,
@@ -21,6 +21,12 @@ import { commandCodeHookService } from '../command-code/hook-service'
import { grokHookService } from '../grok/hook-service'
import { copilotHookService } from '../copilot/hook-service'
import { hermesHookService } from '../hermes/hook-service'
import {
copyClaudeWorkflowResumeCommand,
revealClaudeWorkflowScript,
revealClaudeWorkflowTranscripts
} from '../agent-hooks/claude-workflow-action-registry'
import type { ClaudeWorkflowRecoveryActionResult } from '../../shared/claude-workflow-actions'
// Why: install/remove are intentionally not exposed to the renderer. Orca
// auto-installs managed hooks at app startup (see src/main/index.ts), so a
@@ -46,6 +52,9 @@ export function registerAgentHookHandlers(): void {
ipcMain.removeHandler('agentHooks:hermesStatus')
ipcMain.removeHandler('agentStatus:getSnapshot')
ipcMain.removeHandler('agentStatus:inferInterrupt')
ipcMain.removeHandler('agentStatus:copyWorkflowResumeCommand')
ipcMain.removeHandler('agentStatus:revealWorkflowScript')
ipcMain.removeHandler('agentStatus:revealWorkflowTranscripts')
ipcMain.removeHandler('agentStatus:getMigrationUnsupportedSnapshot')
// Why: agentStatus:drop is sent fire-and-forget from the renderer via
// ipcRenderer.send(); we listen with ipcMain.on (not handle) so we don't
@@ -78,6 +87,21 @@ export function registerAgentHookHandlers(): void {
}
return agentHookServer.inferInterrupt(request as AgentInterruptInferenceRequest)
})
ipcMain.handle(
'agentStatus:copyWorkflowResumeCommand',
(_event, workflowId: unknown): ClaudeWorkflowRecoveryActionResult =>
copyClaudeWorkflowResumeCommand(workflowId, (text) => clipboard.writeText(text))
)
ipcMain.handle(
'agentStatus:revealWorkflowScript',
(_event, workflowId: unknown): ClaudeWorkflowRecoveryActionResult =>
revealClaudeWorkflowScript(workflowId, (filePath) => shell.showItemInFolder(filePath))
)
ipcMain.handle(
'agentStatus:revealWorkflowTranscripts',
(_event, workflowId: unknown): ClaudeWorkflowRecoveryActionResult =>
revealClaudeWorkflowTranscripts(workflowId, (dirPath) => shell.showItemInFolder(dirPath))
)
ipcMain.handle(
'agentStatus:getMigrationUnsupportedSnapshot',
(): MigrationUnsupportedPtyEntry[] => getMigrationUnsupportedPtySnapshot()
+4
View File
@@ -173,6 +173,7 @@ import type {
AgentStatusIpcPayload,
MigrationUnsupportedPtyEntry
} from '../shared/agent-status-types'
import type { ClaudeWorkflowRecoveryActionResult } from '../shared/claude-workflow-actions'
import type { AgentInterruptInferenceRequest } from '../shared/agent-interrupt-intent'
import type {
RuntimeBrowserDriverState,
@@ -2149,6 +2150,9 @@ export type PreloadApi = {
/** Return the current main-process hook cache after renderer hydration. */
getSnapshot: () => Promise<AgentStatusIpcPayload[]>
inferInterrupt: (request: AgentInterruptInferenceRequest) => Promise<boolean>
copyWorkflowResumeCommand: (workflowId: string) => Promise<ClaudeWorkflowRecoveryActionResult>
revealWorkflowScript: (workflowId: string) => Promise<ClaudeWorkflowRecoveryActionResult>
revealWorkflowTranscripts: (workflowId: string) => Promise<ClaudeWorkflowRecoveryActionResult>
/** Listen for PTYs that still use a legacy numeric pane key but have
* registry-backed UUID pane proof. */
onMigrationUnsupported: (callback: (entry: MigrationUnsupportedPtyEntry) => void) => () => void
+7
View File
@@ -109,6 +109,7 @@ import type {
AgentStatusIpcPayload,
MigrationUnsupportedPtyEntry
} from '../shared/agent-status-types'
import type { ClaudeWorkflowRecoveryActionResult } from '../shared/claude-workflow-actions'
import type { AgentInterruptInferenceRequest } from '../shared/agent-interrupt-intent'
import type {
SpeechErrorEvent,
@@ -3341,6 +3342,12 @@ const api = {
ipcRenderer.invoke('agentStatus:getSnapshot'),
inferInterrupt: (request: AgentInterruptInferenceRequest): Promise<boolean> =>
ipcRenderer.invoke('agentStatus:inferInterrupt', request),
copyWorkflowResumeCommand: (workflowId: string): Promise<ClaudeWorkflowRecoveryActionResult> =>
ipcRenderer.invoke('agentStatus:copyWorkflowResumeCommand', workflowId),
revealWorkflowScript: (workflowId: string): Promise<ClaudeWorkflowRecoveryActionResult> =>
ipcRenderer.invoke('agentStatus:revealWorkflowScript', workflowId),
revealWorkflowTranscripts: (workflowId: string): Promise<ClaudeWorkflowRecoveryActionResult> =>
ipcRenderer.invoke('agentStatus:revealWorkflowTranscripts', workflowId),
onMigrationUnsupported: (
callback: (entry: MigrationUnsupportedPtyEntry) => void
): (() => void) => {
@@ -1,3 +1,6 @@
/* eslint-disable max-lines -- Why: this row owns the dense agent-row layout,
expansion behavior, nested controls, and secondary status lines in one
component so dashboard/sidebar row rendering stays pixel-aligned. */
import React, { useState, useCallback } from 'react'
import { X, Wrench, ChevronDown } from 'lucide-react'
import { cn } from '@/lib/utils'
@@ -7,7 +10,9 @@ import { agentTypeToIconAgent, formatAgentTypeLabel } from '@/lib/agent-status'
import CommentMarkdown from '@/components/sidebar/CommentMarkdown'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { DashboardAgentChildDisclosure } from './DashboardAgentChildDisclosure'
import { DashboardAgentWorkflowActions } from './DashboardAgentWorkflowActions'
import type { AgentStatusState } from '../../../../shared/agent-status-types'
import type { ClaudeWorkflowRecoveryMetadata } from '../../../../shared/claude-workflow-actions'
import type { DashboardAgentRow as DashboardAgentRowData } from './useDashboardData'
// Why: the dashboard tracks its own rollup states (incl. 'idle'); narrow to the
@@ -75,6 +80,7 @@ type Props = {
* through so the caller can acknowledge (mark-visited) the specific row
* that was clicked, without having to re-derive it from the tab id. */
onActivate: (tabId: string, paneKey: string) => void
onOpenWorkflowParent?: (workflow: ClaudeWorkflowRecoveryMetadata) => boolean
/**
* Why: the relative-time labels ("Xm ago") need a periodic re-render to stay
* honest. We accept `now` from a parent container so a single 30s tick owned
@@ -127,6 +133,7 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({
agent,
onDismiss,
onActivate,
onOpenWorkflowParent,
now,
isUnvisited = false,
stateDotSize = 'md',
@@ -186,6 +193,13 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({
},
[onActivate, agent.tab.id, agent.paneKey]
)
const workflowRecovery = agent.entry.workflowRecovery
const fallbackOpenWorkflowParent = useCallback(() => {
if (!workflowRecovery) {
return
}
onActivate(agent.tab.id, workflowRecovery.parentPaneKey)
}, [agent.tab.id, onActivate, workflowRecovery])
const startedAt = agent.startedAt > 0 ? agent.startedAt : null
const doneAt = lastEnteredDoneAt(agent)
const prompt = agent.entry.prompt.trim()
@@ -354,6 +368,11 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({
>
{displayLabel}
</span>
{workflowRecovery?.isStale && (
<span className="shrink-0 text-[10px] leading-none text-muted-foreground/70">
workflow stale
</span>
)}
{/* Why: "+N" badge mirrors the leading chevron — without it the
parent row reads identical to a leaf row when collapsed, and the
child count is invisible. Hidden when expanded because the
@@ -370,6 +389,15 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({
place. State belongs in the leading gutter; repeating it here as
text makes interrupted rows look like the old badge treatment. */}
<span className="ml-auto flex shrink-0 items-center gap-1.5">
{workflowRecovery && (
<DashboardAgentWorkflowActions
workflowRecovery={workflowRecovery}
onOpenWorkflowParent={onOpenWorkflowParent}
fallbackOpenParent={fallbackOpenWorkflowParent}
stopMouseDown={stopMouseDown}
stopKeyDown={stopKeyDown}
/>
)}
{/* Why: timestamp and dismiss-X share a single slot so passive
rows show "time ago" and hovered rows swap in the X — no
reserved-space gap, no competing columns. Grid stacks both
@@ -0,0 +1,196 @@
import { Clipboard, Ellipsis, FileCode2, FolderOpen, PanelTopOpen } from 'lucide-react'
import React from 'react'
import { toast } from 'sonner'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import type {
ClaudeWorkflowRecoveryActionResult,
ClaudeWorkflowRecoveryDisabledReason,
ClaudeWorkflowRecoveryMetadata
} from '../../../../shared/claude-workflow-actions'
type Props = {
workflowRecovery: ClaudeWorkflowRecoveryMetadata
onOpenWorkflowParent?: (workflow: ClaudeWorkflowRecoveryMetadata) => boolean
fallbackOpenParent: () => void
stopMouseDown: (event: React.MouseEvent) => void
stopKeyDown: (event: React.KeyboardEvent) => void
}
function workflowDisabledLabel(reason: ClaudeWorkflowRecoveryDisabledReason): string {
switch (reason) {
case 'missing-script':
return 'Missing script path'
case 'missing-run-id':
return 'Missing resume id'
case 'missing-transcripts':
return 'No transcripts'
case 'remote-path':
return 'Remote path'
case 'invalid-path':
return 'Invalid path'
case 'parent-pane-gone':
return 'Parent pane gone'
default:
return 'Unavailable'
}
}
function workflowActionErrorToast(result: ClaudeWorkflowRecoveryActionResult): string {
if (result.ok) {
return ''
}
switch (result.reason) {
case 'missing-path':
return 'Workflow path is missing.'
case 'remote-path':
return 'Remote workflow paths cannot be revealed locally.'
case 'not-found':
return 'Workflow file was not found.'
case 'wrong-kind':
return 'Workflow path has the wrong type.'
case 'stale-id':
return 'Workflow action expired.'
case 'clipboard-failed':
return 'Clipboard write failed.'
case 'launch-failed':
return 'Could not reveal workflow path.'
case 'invalid-path':
return 'Workflow path is invalid.'
case 'missing-run-id':
return 'Workflow resume id is missing.'
default:
return 'Workflow action failed.'
}
}
function formatWorkflowMenuLabel(
base: string,
action: ClaudeWorkflowRecoveryMetadata['actions'][keyof ClaudeWorkflowRecoveryMetadata['actions']]
): string {
return action.available ? base : `${base} - ${workflowDisabledLabel(action.disabledReason)}`
}
function handleWorkflowResult(result: ClaudeWorkflowRecoveryActionResult, success: string): void {
if (result.ok) {
toast.success(success)
return
}
toast.error(workflowActionErrorToast(result))
}
const workflowMenuItemClassName = 'whitespace-nowrap'
export function DashboardAgentWorkflowActions({
workflowRecovery,
onOpenWorkflowParent,
fallbackOpenParent,
stopMouseDown,
stopKeyDown
}: Props) {
const [menuOpen, setMenuOpen] = React.useState(false)
const [tooltipOpen, setTooltipOpen] = React.useState(false)
const handleCopyWorkflowResume = (): void => {
void window.api.agentStatus
.copyWorkflowResumeCommand(workflowRecovery.workflowId)
.then((result) => handleWorkflowResult(result, 'Copied workflow resume text.'))
}
const handleRevealWorkflowScript = (): void => {
void window.api.agentStatus
.revealWorkflowScript(workflowRecovery.workflowId)
.then((result) => handleWorkflowResult(result, 'Revealed workflow script.'))
}
const handleRevealWorkflowTranscripts = (): void => {
void window.api.agentStatus
.revealWorkflowTranscripts(workflowRecovery.workflowId)
.then((result) => handleWorkflowResult(result, 'Revealed workflow transcripts.'))
}
const handleOpenWorkflowParent = (): void => {
const opened = onOpenWorkflowParent ? onOpenWorkflowParent(workflowRecovery) : true
if (opened) {
if (!onOpenWorkflowParent) {
fallbackOpenParent()
}
return
}
toast.error('Parent Claude pane is no longer available.')
}
return (
<DropdownMenu modal={false} open={menuOpen} onOpenChange={setMenuOpen}>
<Tooltip open={!menuOpen && tooltipOpen} onOpenChange={setTooltipOpen}>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<button
type="button"
className="inline-flex size-5 items-center justify-center rounded-sm text-muted-foreground/70 hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
aria-label="Workflow actions"
onClick={(event) => event.stopPropagation()}
onMouseDown={stopMouseDown}
onKeyDown={stopKeyDown}
>
<Ellipsis className="size-3.5" />
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
Workflow actions
</TooltipContent>
</Tooltip>
<DropdownMenuContent
align="end"
className="w-64 border-border/80 bg-popover text-popover-foreground shadow-lg backdrop-blur-none"
onClick={(event) => event.stopPropagation()}
>
<DropdownMenuItem
className={workflowMenuItemClassName}
disabled={!workflowRecovery.actions.copyResumeCommand.available}
onSelect={handleCopyWorkflowResume}
>
<Clipboard className="size-3.5" />
{formatWorkflowMenuLabel(
'Copy resume command',
workflowRecovery.actions.copyResumeCommand
)}
</DropdownMenuItem>
<DropdownMenuItem
className={workflowMenuItemClassName}
disabled={!workflowRecovery.actions.revealScript.available}
onSelect={handleRevealWorkflowScript}
>
<FileCode2 className="size-3.5" />
{formatWorkflowMenuLabel('Reveal workflow script', workflowRecovery.actions.revealScript)}
</DropdownMenuItem>
<DropdownMenuItem
className={workflowMenuItemClassName}
disabled={!workflowRecovery.actions.revealTranscripts.available}
onSelect={handleRevealWorkflowTranscripts}
>
<FolderOpen className="size-3.5" />
{formatWorkflowMenuLabel(
'Reveal transcripts',
workflowRecovery.actions.revealTranscripts
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className={workflowMenuItemClassName}
disabled={!workflowRecovery.actions.openParentPane.available}
onSelect={handleOpenWorkflowParent}
>
<PanelTopOpen className="size-3.5" />
{formatWorkflowMenuLabel(
'Open parent Claude pane',
workflowRecovery.actions.openParentPane
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -11,6 +11,7 @@ import {
import type { Repo, Worktree, TerminalTab } from '../../../../shared/types'
import { parsePaneKey } from '../../../../shared/stable-pane-id'
import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry'
import { refreshClaudeWorkflowRecoveryStaleState } from '../../../../shared/claude-workflow-actions'
// ─── Shared data types ────────────────────────────────────────────────────────
@@ -80,7 +81,12 @@ function buildAgentRowsForWorktree(
(entry.state === 'working' || entry.state === 'blocked' || entry.state === 'waiting')
rows.push({
paneKey: entry.paneKey,
entry,
entry: entry.workflowRecovery
? {
...entry,
workflowRecovery: refreshClaudeWorkflowRecoveryStaleState(entry.workflowRecovery, now)
}
: entry,
tab,
agentType: entry.agentType ?? 'unknown',
state: shouldDecay ? 'idle' : entry.state,
@@ -8,6 +8,7 @@ import { useWorktreeAgentRows } from './useWorktreeAgentRows'
import { cn } from '@/lib/utils'
import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData'
import { parsePaneKey } from '../../../../shared/stable-pane-id'
import type { ClaudeWorkflowRecoveryMetadata } from '../../../../shared/claude-workflow-actions'
import { dismissStaleAgentRowByKey } from '../terminal-pane/stale-agent-row'
import { useFocusedAgentPaneKey } from './focused-agent-row-highlight'
@@ -184,6 +185,30 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({
[worktreeId]
)
const handleOpenWorkflowParent = useCallback(
(workflow: ClaudeWorkflowRecoveryMetadata): boolean => {
if (workflow.worktreeId && workflow.worktreeId !== worktreeId) {
return false
}
const parsed = parsePaneKey(workflow.parentPaneKey)
if (!parsed) {
return false
}
const tabs = useAppStore.getState().tabsByWorktree[worktreeId] ?? []
if (!tabs.some((tab) => tab.id === parsed.tabId)) {
return false
}
activateAndRevealWorktree(worktreeId)
activateTabAndFocusPane(parsed.tabId, parsed.leafId, {
ackPaneKeyOnSuccess: workflow.parentPaneKey,
flashFocusedPane: true,
scrollToBottomIfOutputSinceLastView: true
})
return true
},
[worktreeId]
)
// Why: own one 30s tick per non-empty inline list. Cards with zero agents
// never mount this component (see WorktreeCardAgents), so idle worktrees
// don't pay any timer cost.
@@ -241,6 +266,7 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({
agent={agent}
onDismiss={handleDismissAgent}
onActivate={handleActivateAgentTab}
onOpenWorkflowParent={handleOpenWorkflowParent}
now={now}
// Why: bold an agent row until the user has visited its tab.
// useAutoAckViewedAgent acks automatically when the user
@@ -14,6 +14,7 @@ import {
import { parsePaneKey } from '../../../../shared/stable-pane-id'
import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry'
import { applyAgentRowLineage } from '@/components/dashboard/agent-row-lineage'
import { refreshClaudeWorkflowRecoveryStaleState } from '../../../../shared/claude-workflow-actions'
// Why: stable empty-array references so narrow selectors return the same
// reference when there's nothing for this worktree. Without stable empties,
@@ -247,7 +248,15 @@ export function buildWorktreeAgentRows(args: {
(entry.state === 'working' || entry.state === 'blocked' || entry.state === 'waiting')
rows.push({
paneKey: entry.paneKey,
entry,
entry: entry.workflowRecovery
? {
...entry,
workflowRecovery: refreshClaudeWorkflowRecoveryStaleState(
entry.workflowRecovery,
args.now
)
}
: entry,
tab,
agentType: entry.agentType ?? 'unknown',
state: shouldDecay ? 'idle' : entry.state,
@@ -263,7 +272,15 @@ export function buildWorktreeAgentRows(args: {
}
rows.push({
paneKey: ra.entry.paneKey,
entry: ra.entry,
entry: ra.entry.workflowRecovery
? {
...ra.entry,
workflowRecovery: refreshClaudeWorkflowRecoveryStaleState(
ra.entry.workflowRecovery,
args.now
)
}
: ra.entry,
tab: ra.tab,
agentType: ra.agentType,
state: 'done',
+8 -3
View File
@@ -2121,9 +2121,14 @@ export function useIpcEvents(): void {
) {
return 'dropped'
}
const statusPayload = data.orchestration
? { ...payload, orchestration: data.orchestration }
: payload
const statusPayload =
data.orchestration || data.workflowRecovery
? {
...payload,
...(data.orchestration ? { orchestration: data.orchestration } : {}),
...(data.workflowRecovery ? { workflowRecovery: data.workflowRecovery } : {})
}
: payload
store.setAgentStatus(data.paneKey, statusPayload, title, {
updatedAt: data.receivedAt,
stateStartedAt: data.stateStartedAt
@@ -46,7 +46,11 @@ export function createFreshnessScheduler(deps: FreshnessSchedulerDeps): Freshnes
// future timer: the setAgentStatus write already bumped the epoch, so
// freshness-aware selectors can decay them immediately on that render.
for (const entry of entries) {
const expiryAt = entry.updatedAt + AGENT_STATUS_STALE_AFTER_MS
const workflowExpiryAt =
entry.workflowRecovery?.hasActiveChildWork === true
? entry.workflowRecovery.updatedAt + AGENT_STATUS_STALE_AFTER_MS
: Number.POSITIVE_INFINITY
const expiryAt = Math.min(entry.updatedAt + AGENT_STATUS_STALE_AFTER_MS, workflowExpiryAt)
if (expiryAt > now) {
nextExpiryAt = Math.min(nextExpiryAt, expiryAt)
}
@@ -60,6 +60,42 @@ describe('agent status freshness expiry', () => {
// No additional bump since the entry was removed before the timer fires
expect(store.getState().agentStatusEpoch).toBe(2)
})
it('advances agentStatusEpoch when workflow recovery metadata crosses stale threshold', async () => {
vi.useFakeTimers()
vi.setSystemTime(10_000)
const store = createTestStore()
store.getState().setAgentStatus(
'tab-1:1',
{
state: 'done',
prompt: 'p',
agentType: 'claude',
workflowRecovery: {
workflowId: 'wf-1',
parentPaneKey: 'tab-1:1',
updatedAt: 10_000,
hasActiveChildWork: true,
isStale: false,
isResumable: true,
actions: {
copyResumeCommand: { available: true },
revealScript: { available: true },
revealTranscripts: { available: true },
openParentPane: { available: true }
}
}
},
'claude',
{ updatedAt: 1_000, stateStartedAt: 1_000 }
)
await flushMicrotasks()
vi.advanceTimersByTime(AGENT_STATUS_STALE_AFTER_MS + 1)
expect(store.getState().agentStatusEpoch).toBe(2)
})
})
describe('agent status tool + assistant fields', () => {
@@ -14,6 +14,7 @@ import {
import type { TerminalTab } from '../../../../shared/types'
import { isExplicitAgentStatusFresh } from '@/lib/agent-status'
import { createFreshnessScheduler } from './agent-status-freshness-scheduler'
import type { ClaudeWorkflowRecoveryMetadata } from '../../../../shared/claude-workflow-actions'
/** Snapshot of a finished (or vanished) agent status entry, kept around so
* the dashboard + sidebar hover can continue showing the completion until the
@@ -55,7 +56,10 @@ export type AgentStatusSlice = {
/** Update or insert an agent status entry from a status payload. */
setAgentStatus: (
paneKey: string,
payload: ParsedAgentStatusPayload & { orchestration?: AgentStatusOrchestrationContext },
payload: ParsedAgentStatusPayload & {
orchestration?: AgentStatusOrchestrationContext
workflowRecovery?: ClaudeWorkflowRecoveryMetadata
},
terminalTitle?: string,
timing?: { updatedAt?: number; stateStartedAt?: number }
) => void
@@ -257,6 +261,7 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
// worker completes and the active dispatch closes. Preserve the last
// known parent-child link so done/retained rows stay grouped.
orchestration: payload.orchestration ?? existing?.orchestration,
workflowRecovery: payload.workflowRecovery ?? existing?.workflowRecovery,
// Why: interrupted lives on `done` only. parseAgentStatusPayload
// already clamps it to `undefined` for non-done states, so writing
// the field through directly preserves truth for done and resets
@@ -295,6 +300,7 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
entry.toolInput !== existing.toolInput ||
entry.lastAssistantMessage !== existing.lastAssistantMessage ||
entry.orchestration !== existing.orchestration ||
entry.workflowRecovery !== existing.workflowRecovery ||
entry.interrupted !== existing.interrupted)
const retentionRelevantChange = sortRelevantChange || doneRetentionFieldsChanged
// Why: a new status event means the agent is live again — lift any
+3
View File
@@ -512,6 +512,9 @@ function createWebPreloadApi(): Partial<PreloadApi> {
onSet: () => noopUnsubscribe,
getSnapshot: () => Promise.resolve([]),
inferInterrupt: () => Promise.resolve(false),
copyWorkflowResumeCommand: () => Promise.resolve({ ok: false, reason: 'stale-id' }),
revealWorkflowScript: () => Promise.resolve({ ok: false, reason: 'stale-id' }),
revealWorkflowTranscripts: () => Promise.resolve({ ok: false, reason: 'stale-id' }),
onMigrationUnsupported: () => noopUnsubscribe,
onMigrationUnsupportedClear: () => noopUnsubscribe,
getMigrationUnsupportedSnapshot: () => Promise.resolve([]),
+206
View File
@@ -28,6 +28,10 @@ import {
import { join } from 'path'
import { parseAgentStatusPayload, type ParsedAgentStatusPayload } from './agent-status-types'
import {
buildClaudeWorkflowId,
type ClaudeWorkflowRecoveryHookMetadata
} from './claude-workflow-actions'
import { ORCA_HOOK_PROTOCOL_VERSION } from './agent-hook-types'
import { REMOTE_AGENT_HOOK_ENV, type AgentHookSource } from './agent-hook-relay'
import { parsePaneKey } from './stable-pane-id'
@@ -177,6 +181,9 @@ export type AgentHookEventPayload = {
toolAgentId?: string
/** Agent/subagent type from the source hook payload, when present. */
toolAgentType?: string
/** Raw Claude workflow recovery fields. Main converts this into renderer-safe
* action metadata and keeps the paths in its action lookup. */
workflowRecoveryLookup?: ClaudeWorkflowRecoveryHookMetadata
/** True when this event is a relay cache replay rather than a live hook. */
isReplay?: boolean
payload: ParsedAgentStatusPayload
@@ -550,6 +557,42 @@ function readFirstString(
return undefined
}
function readFirstFiniteNumber(
record: Record<string, unknown>,
keys: readonly string[]
): number | undefined {
for (const key of keys) {
const value = record[key]
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
return value
}
if (typeof value === 'string') {
const parsed = Number(value)
if (Number.isFinite(parsed) && parsed > 0) {
return parsed
}
const time = Date.parse(value)
if (Number.isFinite(time) && time > 0) {
return time
}
}
}
return undefined
}
function readFirstBoolean(
record: Record<string, unknown>,
keys: readonly string[]
): boolean | undefined {
for (const key of keys) {
const value = record[key]
if (typeof value === 'boolean') {
return value
}
}
return undefined
}
function parseJsonObjectString(value: unknown): Record<string, unknown> | undefined {
if (typeof value !== 'string' || value.trim().length === 0) {
return undefined
@@ -564,6 +607,165 @@ function parseJsonObjectString(value: unknown): Record<string, unknown> | undefi
}
}
function collectWorkflowRecords(value: unknown, depth = 0, out: Record<string, unknown>[] = []) {
if (depth > 5 || typeof value !== 'object' || value === null) {
return out
}
if (Array.isArray(value)) {
for (const item of value) {
collectWorkflowRecords(item, depth + 1, out)
}
return out
}
const record = value as Record<string, unknown>
out.push(record)
for (const nested of Object.values(record)) {
if (typeof nested === 'string') {
const parsed = parseJsonObjectString(nested)
if (parsed) {
collectWorkflowRecords(parsed, depth + 1, out)
}
} else {
collectWorkflowRecords(nested, depth + 1, out)
}
}
return out
}
function readFirstWorkflowString(
records: readonly Record<string, unknown>[],
keys: readonly string[]
): string | undefined {
for (const record of records) {
const value = readFirstString(record, keys)
if (value) {
return value
}
}
return undefined
}
function readFirstWorkflowBoolean(
records: readonly Record<string, unknown>[],
keys: readonly string[]
): boolean | undefined {
for (const record of records) {
const value = readFirstBoolean(record, keys)
if (value !== undefined) {
return value
}
}
return undefined
}
function readFirstWorkflowNumber(
records: readonly Record<string, unknown>[],
keys: readonly string[]
): number | undefined {
for (const record of records) {
const value = readFirstFiniteNumber(record, keys)
if (value !== undefined) {
return value
}
}
return undefined
}
function normalizeWorkflowStatus(value: string | undefined): string {
return (
value
?.trim()
.toLowerCase()
.replace(/[-_\s]+/g, '-') ?? ''
)
}
function extractClaudeWorkflowRecovery(
paneKey: string,
worktreeId: string | undefined,
hookPayload: Record<string, unknown>
): ClaudeWorkflowRecoveryHookMetadata | undefined {
const toolName = readString(hookPayload, 'tool_name')
const candidateRecords = collectWorkflowRecords({
payload: hookPayload,
tool_input: hookPayload.tool_input,
tool_response: hookPayload.tool_response
})
const scriptPath = readFirstWorkflowString(candidateRecords, ['scriptPath', 'script_path'])
const resumeFromRunId = readFirstWorkflowString(candidateRecords, [
'resumeFromRunId',
'resume_from_run_id'
])
const transcriptDir = readFirstWorkflowString(candidateRecords, [
'transcriptDir',
'transcript_dir',
'transcriptsDir',
'transcripts_dir'
])
const transcriptPath = readFirstWorkflowString(candidateRecords, [
'transcriptPath',
'transcript_path'
])
const looksLikeWorkflowTool =
typeof toolName === 'string' && toolName.trim().toLowerCase() === 'workflow'
if (!scriptPath && !resumeFromRunId && !looksLikeWorkflowTool) {
return undefined
}
const explicitActive = readFirstWorkflowBoolean(candidateRecords, [
'hasActiveChildWork',
'has_active_child_work',
'activeChildWork',
'active_child_work',
'childActive',
'child_active',
'isRunning',
'is_running',
'active'
])
const status = normalizeWorkflowStatus(
readFirstWorkflowString(candidateRecords, ['workflowStatus', 'workflow_status', 'status'])
)
const finished =
status === 'done' ||
status === 'completed' ||
status === 'complete' ||
status === 'succeeded' ||
status === 'success' ||
status === 'failed' ||
status === 'cancelled' ||
status === 'canceled'
const hasActiveChildWork = explicitActive ?? !finished
return {
workflowId:
readFirstWorkflowString(candidateRecords, ['workflowId', 'workflow_id']) ??
buildClaudeWorkflowId({
parentPaneKey: paneKey,
scriptPath,
resumeFromRunId,
transcriptDir,
transcriptPath
}),
parentPaneKey:
readFirstWorkflowString(candidateRecords, ['parentPaneKey', 'parent_pane_key']) ?? paneKey,
worktreeId,
scriptPath,
transcriptDir,
transcriptPath,
resumeFromRunId,
hasActiveChildWork,
updatedAt: readFirstWorkflowNumber(candidateRecords, [
'workflowUpdatedAt',
'workflow_updated_at',
'scriptMtimeMs',
'script_mtime_ms',
'scriptModifiedAt',
'script_modified_at'
])
}
}
function extractToolResponseText(toolResponse: unknown): string | undefined {
if (typeof toolResponse === 'string' && toolResponse.length > 0) {
return toolResponse
@@ -2858,6 +3060,10 @@ export function normalizeHookPayload(
toolUseId: readFirstString(hookPayloadRecord, ['tool_use_id', 'toolUseId']),
toolAgentId: readFirstString(hookPayloadRecord, ['agent_id', 'agentId']),
toolAgentType: readString(hookPayloadRecord, 'agent_type'),
workflowRecoveryLookup:
source === 'claude'
? extractClaudeWorkflowRecovery(paneKey, worktreeId, hookPayloadRecord)
: undefined,
payload
}
: null
+4
View File
@@ -1,3 +1,5 @@
import type { ClaudeWorkflowRecoveryMetadata } from './claude-workflow-actions'
// ─── Explicit agent status (reported via native agent hooks → IPC) ──────────
// These types define the normalized status that Orca receives from Claude,
// Codex, and other explicit integrations. Agent state normally comes from
@@ -102,6 +104,7 @@ export type AgentStatusEntry = {
* Why: parent/child agent hierarchy is pane-level state, not worktree
* lineage; workers often run in the same worktree as their coordinator. */
orchestration?: AgentStatusOrchestrationContext
workflowRecovery?: ClaudeWorkflowRecoveryMetadata
}
export type MigrationUnsupportedPtyEntry = {
@@ -160,6 +163,7 @@ export type AgentStatusIpcPayload = ParsedAgentStatusPayload & {
/** Timestamp (ms) when the current state first appeared for this pane. */
stateStartedAt: number
orchestration?: AgentStatusOrchestrationContext
workflowRecovery?: ClaudeWorkflowRecoveryMetadata
}
/** Maximum character length for the prompt field. Truncated on parse. */
@@ -0,0 +1,94 @@
import { describe, expect, it } from 'vitest'
import {
buildClaudeWorkflowResumeText,
deriveClaudeWorkflowRecoveryMetadata
} from './claude-workflow-actions'
import { AGENT_STATUS_STALE_AFTER_MS } from './agent-status-types'
describe('Claude workflow recovery actions', () => {
it('marks active workflows stale only after the workflow update threshold', () => {
const base = {
workflowId: 'wf-1',
parentPaneKey: 'tab:leaf',
connectionId: null,
scriptPath: '/tmp/workflow.js',
resumeFromRunId: 'run-1',
hasActiveChildWork: true,
receivedAt: 1_000,
workflowUpdatedAt: 1_000
}
expect(
deriveClaudeWorkflowRecoveryMetadata(base, 1_000 + AGENT_STATUS_STALE_AFTER_MS).isStale
).toBe(false)
expect(
deriveClaudeWorkflowRecoveryMetadata(base, 1_001 + AGENT_STATUS_STALE_AFTER_MS).isStale
).toBe(true)
expect(
deriveClaudeWorkflowRecoveryMetadata({ ...base, hasActiveChildWork: false }, 99_000_000)
.isStale
).toBe(false)
})
it('requires both script path and resume run id for copy availability', () => {
const metadata = deriveClaudeWorkflowRecoveryMetadata(
{
workflowId: 'wf-1',
parentPaneKey: 'tab:leaf',
connectionId: null,
resumeFromRunId: 'run-1',
hasActiveChildWork: true,
receivedAt: 1_000
},
1_000
)
expect(metadata.isResumable).toBe(false)
expect(metadata.actions.copyResumeCommand).toEqual({
available: false,
disabledReason: 'missing-script'
})
})
it('keeps copy available for SSH workflows while disabling local reveal actions', () => {
const metadata = deriveClaudeWorkflowRecoveryMetadata(
{
workflowId: 'wf-1',
parentPaneKey: 'tab:leaf',
connectionId: 'conn-1',
scriptPath: '/remote/workflow.js',
transcriptDir: '/remote/transcripts',
resumeFromRunId: 'run-1',
hasActiveChildWork: true,
receivedAt: 1_000
},
1_000
)
expect(metadata.actions.copyResumeCommand).toEqual({ available: true })
expect(metadata.actions.revealScript).toEqual({
available: false,
disabledReason: 'remote-path'
})
expect(metadata.actions.revealTranscripts).toEqual({
available: false,
disabledReason: 'remote-path'
})
})
it('formats resume paste text on separate editable lines', () => {
expect(
buildClaudeWorkflowResumeText({
scriptPath: '/tmp/workflow.js',
resumeFromRunId: 'run-1'
})
).toBe(
[
'Resume this Claude Code workflow:',
'scriptPath: /tmp/workflow.js',
'resumeFromRunId: run-1'
].join('\n')
)
})
})
+164
View File
@@ -0,0 +1,164 @@
import { AGENT_STATUS_STALE_AFTER_MS } from './agent-status-types'
export type ClaudeWorkflowRecoveryDisabledReason =
| 'missing-script'
| 'missing-run-id'
| 'missing-transcripts'
| 'remote-path'
| 'invalid-path'
| 'parent-pane-gone'
export type ClaudeWorkflowRecoveryActionAvailability =
| { available: true }
| { available: false; disabledReason: ClaudeWorkflowRecoveryDisabledReason }
export type ClaudeWorkflowRecoveryActions = {
copyResumeCommand: ClaudeWorkflowRecoveryActionAvailability
revealScript: ClaudeWorkflowRecoveryActionAvailability
revealTranscripts: ClaudeWorkflowRecoveryActionAvailability
openParentPane: ClaudeWorkflowRecoveryActionAvailability
}
export type ClaudeWorkflowRecoveryMetadata = {
workflowId: string
parentPaneKey: string
worktreeId?: string
updatedAt: number
hasActiveChildWork: boolean
isStale: boolean
isResumable: boolean
actions: ClaudeWorkflowRecoveryActions
}
export type ClaudeWorkflowRecoveryActionFailureReason =
| 'missing-path'
| 'remote-path'
| 'not-found'
| 'wrong-kind'
| 'stale-id'
| 'clipboard-failed'
| 'launch-failed'
| 'invalid-path'
| 'missing-run-id'
export type ClaudeWorkflowRecoveryActionResult =
| { ok: true }
| { ok: false; reason: ClaudeWorkflowRecoveryActionFailureReason }
export type ClaudeWorkflowRecoveryHookMetadata = {
workflowId: string
parentPaneKey: string
worktreeId?: string
scriptPath?: string
transcriptDir?: string
transcriptPath?: string
resumeFromRunId?: string
hasActiveChildWork: boolean
updatedAt?: number
}
export type ClaudeWorkflowRecoveryLookupEntry = ClaudeWorkflowRecoveryHookMetadata & {
connectionId: string | null
receivedAt: number
}
export type ClaudeWorkflowRecoveryMetadataInput = {
workflowId: string
parentPaneKey: string
worktreeId?: string
connectionId: string | null
scriptPath?: string
transcriptDir?: string
transcriptPath?: string
resumeFromRunId?: string
hasActiveChildWork: boolean
receivedAt: number
workflowUpdatedAt?: number
}
export function buildClaudeWorkflowResumeText(args: {
scriptPath: string
resumeFromRunId: string
}): string {
return [
'Resume this Claude Code workflow:',
`scriptPath: ${args.scriptPath}`,
`resumeFromRunId: ${args.resumeFromRunId}`
].join('\n')
}
export function buildClaudeWorkflowId(args: {
parentPaneKey: string
scriptPath?: string
resumeFromRunId?: string
transcriptDir?: string
transcriptPath?: string
}): string {
const input = [
args.parentPaneKey,
args.scriptPath ?? '',
args.resumeFromRunId ?? '',
args.transcriptDir ?? '',
args.transcriptPath ?? ''
].join('\0')
let hash = 0x811c9dc5
for (let index = 0; index < input.length; index += 1) {
hash ^= input.charCodeAt(index)
hash = Math.imul(hash, 0x01000193)
}
return `claude-workflow:${(hash >>> 0).toString(16).padStart(8, '0')}`
}
export function deriveClaudeWorkflowRecoveryMetadata(
input: ClaudeWorkflowRecoveryMetadataInput,
now: number
): ClaudeWorkflowRecoveryMetadata {
const workflowUpdatedAt = input.workflowUpdatedAt ?? input.receivedAt
const hasScriptPath = typeof input.scriptPath === 'string' && input.scriptPath.trim().length > 0
const hasRunId =
typeof input.resumeFromRunId === 'string' && input.resumeFromRunId.trim().length > 0
const hasTranscripts =
(typeof input.transcriptDir === 'string' && input.transcriptDir.trim().length > 0) ||
(typeof input.transcriptPath === 'string' && input.transcriptPath.trim().length > 0)
const isRemote = input.connectionId !== null
const isResumable = hasScriptPath && hasRunId
return {
workflowId: input.workflowId,
parentPaneKey: input.parentPaneKey,
worktreeId: input.worktreeId,
updatedAt: workflowUpdatedAt,
hasActiveChildWork: input.hasActiveChildWork,
isStale:
input.hasActiveChildWork === true &&
now - Math.max(workflowUpdatedAt, input.receivedAt) > AGENT_STATUS_STALE_AFTER_MS,
isResumable,
actions: {
copyResumeCommand: !hasScriptPath
? { available: false, disabledReason: 'missing-script' }
: !hasRunId
? { available: false, disabledReason: 'missing-run-id' }
: { available: true },
revealScript: !hasScriptPath
? { available: false, disabledReason: 'missing-script' }
: isRemote
? { available: false, disabledReason: 'remote-path' }
: { available: true },
revealTranscripts: !hasTranscripts
? { available: false, disabledReason: 'missing-transcripts' }
: isRemote
? { available: false, disabledReason: 'remote-path' }
: { available: true },
openParentPane: { available: true }
}
}
}
export function refreshClaudeWorkflowRecoveryStaleState(
metadata: ClaudeWorkflowRecoveryMetadata,
now: number
): ClaudeWorkflowRecoveryMetadata {
const isStale =
metadata.hasActiveChildWork === true && now - metadata.updatedAt > AGENT_STATUS_STALE_AFTER_MS
return metadata.isStale === isStale ? metadata : { ...metadata, isStale }
}