* fix: propagate hook-only agent status to Remote Orca Server clients On a headless Remote Orca Server, agent-status hooks (OSC 9999) updated the retained row map but never republished PTY-backed session snapshots — only terminal *title* changes did. Paired desktop/web/mobile clients therefore kept a stale agent state (e.g. opencode working/idle) until relaunch, and even title-driven updates carried an empty prompt and no agent identity because the snapshot builder only used the title heuristic (#7970). - retainAgentRowSnapshot reports client-visible changes (state, prompt, agent type, tool, interactive prompt, interrupted) so handlePtyData can republish snapshots on hook-only transitions without fanning out a rebuild per repeated same-state hook ping. - buildPtyMobileAgentStatus prefers the fresh retained hook payload over the title-only fallback, so clients see the real state/prompt/agentType and interactive prompts. The non-agent-title suppression (#1437 stuck spinners) still wins unless the hook shows a live tool/question signal, and it now also covers leaf-backed panes with no PTY record. Co-authored-by: Orca <help@stably.ai> * fix: refetch remote projects when the client-events stream replays worktreesChanged/reposChanged emitted during a transport gap are lost, not queued. A quick drop can replay without flipping the environment unreachable, so the reachability-transition refetch never runs and a server-created worktree stays invisible until relaunch (#7970). Request a debounced project refresh on the replay tag, mirroring the SSH-state refetch that already rides it. Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
4baaca601a
commit
e8c84bb704
|
|
@ -11326,6 +11326,121 @@ describe('OrcaRuntimeService', () => {
|
|||
unsubscribe()
|
||||
})
|
||||
|
||||
// #7970: on a headless serve there is no renderer syncing tab.agentStatus, so
|
||||
// hook-only transitions must republish the PTY-backed snapshot themselves and
|
||||
// the snapshot must carry the retained hook payload, not just title evidence.
|
||||
it('republishes mobile session tabs with hook payloads for title-less OSC 9999 transitions', async () => {
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'hook-only-pty' })
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
const events: RuntimeMobileSessionTabsResult[] = []
|
||||
const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot))
|
||||
|
||||
await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, {
|
||||
tabId: 'hook-tab',
|
||||
leafId: HEADLESS_LEAF_ID
|
||||
})
|
||||
events.length = 0
|
||||
|
||||
runtime.onPtyData(
|
||||
'hook-only-pty',
|
||||
'\x1b]9999;{"state":"working","prompt":"fix the tests","agentType":"opencode"}\x07',
|
||||
100
|
||||
)
|
||||
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0]?.tabs[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'terminal',
|
||||
agentStatus: expect.objectContaining({
|
||||
state: 'working',
|
||||
prompt: 'fix the tests',
|
||||
agentType: 'opencode'
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
runtime.onPtyData(
|
||||
'hook-only-pty',
|
||||
'\x1b]9999;{"state":"waiting","prompt":"fix the tests","agentType":"opencode"}\x07',
|
||||
101
|
||||
)
|
||||
|
||||
expect(events).toHaveLength(2)
|
||||
expect(events[1]?.tabs[0]?.type === 'terminal' && events[1].tabs[0].agentStatus).toEqual(
|
||||
expect.objectContaining({ state: 'waiting' })
|
||||
)
|
||||
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it('does not republish mobile session tabs for repeated identical OSC 9999 payloads', async () => {
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'hook-ping-pty' })
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
const events: RuntimeMobileSessionTabsResult[] = []
|
||||
const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot))
|
||||
|
||||
await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, {
|
||||
tabId: 'hook-ping-tab',
|
||||
leafId: HEADLESS_LEAF_ID
|
||||
})
|
||||
events.length = 0
|
||||
|
||||
const payload = '\x1b]9999;{"state":"working","prompt":"same","agentType":"codex"}\x07'
|
||||
runtime.onPtyData('hook-ping-pty', payload, 100)
|
||||
runtime.onPtyData('hook-ping-pty', payload, 101)
|
||||
runtime.onPtyData('hook-ping-pty', payload, 102)
|
||||
|
||||
expect(events).toHaveLength(1)
|
||||
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it('suppresses a retained hook working status once the shell owns the pane title again', async () => {
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'hook-exit-pty' })
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
const events: RuntimeMobileSessionTabsResult[] = []
|
||||
const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot))
|
||||
|
||||
await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, {
|
||||
tabId: 'hook-exit-tab',
|
||||
leafId: HEADLESS_LEAF_ID
|
||||
})
|
||||
events.length = 0
|
||||
|
||||
runtime.onPtyData(
|
||||
'hook-exit-pty',
|
||||
'\x1b]9999;{"state":"working","prompt":"long task","agentType":"codex"}\x07',
|
||||
100
|
||||
)
|
||||
// Agent exits without a hook done event; the shell takes the title back.
|
||||
// The stuck-spinner guard (#1437) must win over the retained hook row.
|
||||
runtime.onPtyData('hook-exit-pty', '\x1b]0;zsh\x07', 101)
|
||||
|
||||
const last = events.at(-1)?.tabs[0]
|
||||
expect(last?.type).toBe('terminal')
|
||||
expect(last?.type === 'terminal' ? last.agentStatus : null).toBeFalsy()
|
||||
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it('stores normalized Pi idle OSC titles that still classify as idle', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
syncSinglePty(runtime)
|
||||
|
|
|
|||
|
|
@ -5533,8 +5533,11 @@ export class OrcaRuntimeService {
|
|||
}
|
||||
}
|
||||
|
||||
this.emitTerminalAgentStatusEvents(ptyId, agentStatusChunk)
|
||||
if (shouldTouchPtyBackedSessionTabs) {
|
||||
const retainedAgentStatusChanged = this.emitTerminalAgentStatusEvents(ptyId, agentStatusChunk)
|
||||
// Why: hook (OSC 9999) transitions often arrive without a title change, so
|
||||
// headless-serve snapshots would never republish and paired remote clients
|
||||
// kept the stale agent state until the next title change (#7970).
|
||||
if (shouldTouchPtyBackedSessionTabs || retainedAgentStatusChanged) {
|
||||
this.touchMobileSessionSnapshotsForPty(ptyId)
|
||||
}
|
||||
|
||||
|
|
@ -5631,12 +5634,14 @@ export class OrcaRuntimeService {
|
|||
return worktreePath && isWindowsAbsolutePathLike(worktreePath) ? 'win32' : 'posix'
|
||||
}
|
||||
|
||||
private emitTerminalAgentStatusEvents(ptyId: string, chunk: ProcessedAgentStatusChunk): void {
|
||||
/** Returns true when any retained agent-row snapshot changed in a
|
||||
* client-visible way, so the caller can republish session snapshots. */
|
||||
private emitTerminalAgentStatusEvents(ptyId: string, chunk: ProcessedAgentStatusChunk): boolean {
|
||||
// Why: snapshot retention (for mobile worktree.ps) must run even when no
|
||||
// renderer listener is attached, so we don't early-return on a missing
|
||||
// onTerminalAgentStatus — only the per-target emit below is gated on it.
|
||||
if (chunk.payloads.length === 0) {
|
||||
return
|
||||
return false
|
||||
}
|
||||
const targets = new Map<
|
||||
string,
|
||||
|
|
@ -5669,9 +5674,17 @@ export class OrcaRuntimeService {
|
|||
connectionId
|
||||
})
|
||||
}
|
||||
let retainedChanged = false
|
||||
for (const payload of chunk.payloads) {
|
||||
for (const target of targets.values()) {
|
||||
this.retainAgentRowSnapshot(ptyId, target.paneKey, target.worktreeId, target.tabId, payload)
|
||||
retainedChanged =
|
||||
this.retainAgentRowSnapshot(
|
||||
ptyId,
|
||||
target.paneKey,
|
||||
target.worktreeId,
|
||||
target.tabId,
|
||||
payload
|
||||
) || retainedChanged
|
||||
if (!this.onTerminalAgentStatus) {
|
||||
continue
|
||||
}
|
||||
|
|
@ -5692,6 +5705,7 @@ export class OrcaRuntimeService {
|
|||
}
|
||||
}
|
||||
}
|
||||
return retainedChanged
|
||||
}
|
||||
|
||||
private retainAgentRowSnapshot(
|
||||
|
|
@ -5700,7 +5714,7 @@ export class OrcaRuntimeService {
|
|||
worktreeId: string | undefined,
|
||||
tabId: string | undefined,
|
||||
payload: ParsedAgentStatusPayload
|
||||
): void {
|
||||
): boolean {
|
||||
const now = Date.now()
|
||||
const previous = this.latestAgentStatusByPaneKey.get(paneKey)
|
||||
// Why: stateStartedAt must mark the transition into the current state, not
|
||||
|
|
@ -5717,6 +5731,17 @@ export class OrcaRuntimeService {
|
|||
stateStartedAt,
|
||||
updatedAt: now
|
||||
})
|
||||
// Client-visible change detection: snapshot republish is gated on this so
|
||||
// repeated same-state hook pings don't fan a rebuild out to every client.
|
||||
return (
|
||||
!previous ||
|
||||
previous.payload.state !== payload.state ||
|
||||
previous.payload.prompt !== payload.prompt ||
|
||||
(previous.payload.agentType ?? null) !== (payload.agentType ?? null) ||
|
||||
(previous.payload.toolName ?? null) !== (payload.toolName ?? null) ||
|
||||
(previous.payload.interactivePrompt ?? null) !== (payload.interactivePrompt ?? null) ||
|
||||
(previous.payload.interrupted ?? false) !== (payload.interrupted ?? false)
|
||||
)
|
||||
}
|
||||
|
||||
private clearAgentRowSnapshotsForPty(ptyId: string): void {
|
||||
|
|
@ -19320,45 +19345,117 @@ export class OrcaRuntimeService {
|
|||
tab: RuntimeMobileSessionTerminalTab,
|
||||
terminalHandle: string | null
|
||||
): { agentStatus: AgentStatusEntry } | Record<string, never> {
|
||||
if (!pty?.lastAgentStatus) {
|
||||
const paneKey = this.getMobileTerminalPaneKey(tab)
|
||||
const retained = this.getFreshRetainedAgentStatusForMobileTab(paneKey, pty, tab)
|
||||
if (!pty?.lastAgentStatus && !retained) {
|
||||
return {}
|
||||
}
|
||||
const ptyTitle = getLatestAgentCandidateTitle(
|
||||
{ title: pty.title, updatedAt: pty.titleUpdatedAt },
|
||||
{ title: pty.lastOscTitle, updatedAt: pty.lastOscTitleAt }
|
||||
)
|
||||
const leaf = this.leaves.get(this.getLeafKey(tab.parentTabId, tab.leafId)) ?? null
|
||||
const ptyTitle = pty
|
||||
? getLatestAgentCandidateTitle(
|
||||
{ title: pty.title, updatedAt: pty.titleUpdatedAt },
|
||||
{ title: pty.lastOscTitle, updatedAt: pty.lastOscTitleAt }
|
||||
)
|
||||
: leaf
|
||||
? getLatestAgentCandidateTitle(
|
||||
{ title: leaf.paneTitle, updatedAt: leaf.paneTitleUpdatedAt },
|
||||
{ title: leaf.lastOscTitle, updatedAt: leaf.lastOscTitleAt }
|
||||
)
|
||||
: null
|
||||
const ptyTitleClassification = classifyAgentTitle(ptyTitle)
|
||||
if (ptyTitle !== null && ptyTitleClassification !== 'agent') {
|
||||
return {}
|
||||
// Why: a non-agent title means the shell owns the pane again (the agent
|
||||
// exited or was replaced) — suppressing here is what clears stuck
|
||||
// spinners (#1437). A live hook signal (question card / active tool) is
|
||||
// authoritative agent activity even under a task-named title, so it
|
||||
// survives the suppression, mirroring the renderer-synced branch above.
|
||||
const hasLiveHookSignal =
|
||||
retained?.payload.interactivePrompt != null || retained?.payload.toolName != null
|
||||
if (!hasLiveHookSignal) {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
const now = pty.lastOutputAt ?? Date.now()
|
||||
const ownerAgent = tab.launchAgent ?? pty.launchAgent ?? pty.foregroundAgent ?? null
|
||||
const ownerAgent = tab.launchAgent ?? pty?.launchAgent ?? pty?.foregroundAgent ?? null
|
||||
const terminalTitle = normalizeCompatibleAgentTitleForOwner(
|
||||
(pty ? getLatestPtyTitle(pty) : null) ?? tab.title,
|
||||
ownerAgent
|
||||
)
|
||||
// Why: hook (OSC 9999) payloads carry the real state, prompt, and agent
|
||||
// identity; the title heuristic below is a fallback with none of that.
|
||||
// Without this, headless-serve clients only ever saw title-derived rows
|
||||
// and hook-only transitions (e.g. opencode waiting) never surfaced (#7970).
|
||||
if (retained) {
|
||||
return {
|
||||
agentStatus: normalizeCompatibleAgentStatusEntryForOwner(
|
||||
{
|
||||
...retained.payload,
|
||||
paneKey,
|
||||
updatedAt: retained.updatedAt,
|
||||
stateStartedAt: retained.stateStartedAt,
|
||||
stateHistory: [],
|
||||
...(terminalHandle ? { terminalHandle } : {}),
|
||||
...((pty?.worktreeId ?? retained.worktreeId)
|
||||
? { worktreeId: pty?.worktreeId ?? retained.worktreeId }
|
||||
: {}),
|
||||
tabId: tab.parentTabId,
|
||||
terminalTitle
|
||||
},
|
||||
ownerAgent
|
||||
)
|
||||
}
|
||||
}
|
||||
const now = pty!.lastOutputAt ?? Date.now()
|
||||
const agentType = ownerAgent ?? undefined
|
||||
return {
|
||||
agentStatus: {
|
||||
state:
|
||||
pty.lastAgentStatus === 'working'
|
||||
pty!.lastAgentStatus === 'working'
|
||||
? 'working'
|
||||
: pty.lastAgentStatus === 'permission'
|
||||
: pty!.lastAgentStatus === 'permission'
|
||||
? 'blocked'
|
||||
: 'done',
|
||||
prompt: '',
|
||||
updatedAt: now,
|
||||
stateStartedAt: now,
|
||||
paneKey: this.getMobileTerminalPaneKey(tab),
|
||||
paneKey,
|
||||
...(terminalHandle ? { terminalHandle } : {}),
|
||||
...(agentType ? { agentType } : {}),
|
||||
worktreeId: pty.worktreeId,
|
||||
worktreeId: pty!.worktreeId,
|
||||
tabId: tab.parentTabId,
|
||||
terminalTitle: normalizeCompatibleAgentTitleForOwner(
|
||||
getLatestPtyTitle(pty) ?? tab.title,
|
||||
ownerAgent
|
||||
),
|
||||
terminalTitle,
|
||||
stateHistory: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The retained OSC 9999 hook row for this mobile tab, when fresh enough to
|
||||
* trust. Looked up by pane identity first, then by PTY ownership because
|
||||
* legacy `pane:N` leaf ids can drift from the hook-side pane key. */
|
||||
private getFreshRetainedAgentStatusForMobileTab(
|
||||
paneKey: string,
|
||||
pty: RuntimePtyWorktreeRecord | null,
|
||||
tab: RuntimeMobileSessionTerminalTab
|
||||
): RuntimeAgentRowSnapshot | null {
|
||||
let retained = this.latestAgentStatusByPaneKey.get(paneKey) ?? null
|
||||
if (!retained) {
|
||||
const ptyId = pty?.ptyId ?? tab.ptyId ?? null
|
||||
if (ptyId) {
|
||||
for (const snapshot of this.latestAgentStatusByPaneKey.values()) {
|
||||
if (snapshot.ptyId !== ptyId) {
|
||||
continue
|
||||
}
|
||||
if (!retained || snapshot.updatedAt > retained.updatedAt) {
|
||||
retained = snapshot
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!retained || Date.now() - retained.updatedAt > AGENT_STATUS_STALE_AFTER_MS) {
|
||||
return null
|
||||
}
|
||||
return retained
|
||||
}
|
||||
|
||||
private findPtyForMobileTerminalTab(
|
||||
worktreeId: string,
|
||||
tab: RuntimeMobileSessionTerminalTab,
|
||||
|
|
|
|||
|
|
@ -1033,6 +1033,12 @@ export function useIpcEvents(): void {
|
|||
getDesiredEnvironmentIds: getRuntimeClientEventEnvironmentIds,
|
||||
subscribe: (environmentId, onEvent, onError) =>
|
||||
subscribeRuntimeClientEvents(environmentId, onEvent, onError, () => {
|
||||
// Why: worktreesChanged/reposChanged during the transport gap are
|
||||
// lost, not queued. A quick drop can replay without ever flipping the
|
||||
// env unreachable, so the reachability-transition refetch never runs
|
||||
// and a server-created worktree stays invisible until relaunch
|
||||
// (#7970). The scheduler debounces, so this stays cheap.
|
||||
runtimeProjectRefreshScheduler.request(environmentId)
|
||||
if (isPairedWebClientWindow()) {
|
||||
return
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue