mirror of
https://github.com/stablyai/orca.git
synced 2026-11-05 19:47:27 +00:00
* docs(agent-status): plan PR 1b at file level Names the five RuntimeAgentRowStore call sites and what each becomes, why terminalHandle has to be stamped before the store can go, and the one intended behavior change. * feat(agent-status): stamp the pane terminal handle on hook-server rows The runtime's retained row store carried the pty binding two readers need. Put that fact on the row that already owns the pane instead, resolved through the same lookup the renderer-facing IPC boundary runs, so the two surfaces cannot disagree about which terminal a pane is. Carried forward when a later write resolves no handle (only main's OSC parse can), and never persisted: a handle belongs to the runtime that issued it. * refactor(agent-status): route the session-tabs republish off the store `retain()` was not only a duplicate store: its boolean return was the signal that republished `session.tabs` for a status-only transition, which no title change covers (#7970). `hook-status-session-tabs-invalidation.ts` already mirrors that change set plus hook restore provenance, so route the signal off the store rather than keep a second comparator. Adds the status-drop arm a user dismissal emits, which the pane-clear fan-out deliberately skips — now load-bearing, because a dismissed row leaves the listing at once. Installed on both hosts. orcad had neither the OSC producer nor this signal, so its runtime observed agent status and published it nowhere; deleting the retained copy without wiring it would list no PTY agents there at all. * refactor(agent-status): delete the runtime's duplicate retained row store `RuntimeAgentRowStore` held the same payload the hook server already holds, so the same pane could legitimately read differently in the sidebar, in `worktree ps`, and on the phone. Both of its readers move onto the store's snapshot in `runtime-hook-agent-row-selection.ts`, and `collectRuntimeWorktreePtyAgentSources` loses the retained-versus-hook reconciliation that only existed because two stores could disagree. `ConnectedPtyEvidence` trades its flat pty-id set for `ptyIdByTerminalHandle`, which is how a row still resolves the connected PTY behind it — the working-terminal rollup's match key, and the last rescue for a row whose pane binding a controller incarnation nulled under it. The one intended behavior change: a row the user dismisses on the desktop leaves `worktree ps` and mobile at once instead of lingering until the pty exits. One store means one dismissal. The suites written against the retained store are rewired to a real AgentHookServer rather than deleted, so each still asserts the listing behavior it named. * docs(agent-status): record what PR 1b landed Past tense, plus two corrections to the plan: `terminalHandle` is not the pty id (they are different identifiers, and the explicit-status reader was already comparing against a real handle), and the legacy numeric pane key is a consequence the plan did not name. * fix(agent-status): harden single-store lifecycle * fix(agent-status): preserve mobile terminal rejoin * fix(agent-status): preserve unverifiable remote rows * fix(agent-status): own PTY row lifecycle in hook server * fix(agent-status): preserve state and renew freshness * fix(agent-status): ignore freshness for dismissed identity rows * fix(agent-status): fence orcad observed identities * fix(orcad): always release daemon adapter on cleanup * fix(agent-status): cover remint and headless lifecycle edges * fix agent status identity recovery gaps * fix(agent-status): suppress duplicate child-only row mutation * test(runtime): preserve hook store wiring in transcript harness --------- Co-authored-by: Merge Sim <sim@local>
314 lines
14 KiB
TypeScript
314 lines
14 KiB
TypeScript
import {
|
|
normalizeCompatibleAgentStatusEntryForOwner,
|
|
normalizeCompatibleAgentTitleForOwner,
|
|
resolveCompatibleAgentTypeForOwner
|
|
} from '../../shared/agent-title-owner'
|
|
import { resolvePaneAgentOwnerRecord } from '../../shared/pane-agent-owner'
|
|
import type { AgentStatusEntry, AgentStatusIpcPayload } from '../../shared/agent-status-types'
|
|
import type {
|
|
RuntimeMobileSessionClientTab,
|
|
RuntimeMobileSessionTabsResult,
|
|
RuntimeMobileSessionTabsSnapshot
|
|
} from '../../shared/runtime-types'
|
|
import { isTerminalLeafId, makePaneKey } from '../../shared/stable-pane-id'
|
|
import { indexAgentStatusRowsByPaneKey } from '../agent-hooks/agent-status-pane-index'
|
|
import {
|
|
renewRuntimeMobileAgentStatusFromPtyTitle,
|
|
selectRuntimeHookAgentRowForPane
|
|
} from './runtime-mobile-agent-status-projection'
|
|
import { finalizeRuntimeMobileSessionTabsResult } from './runtime-mobile-session-result-finalization'
|
|
import type { RuntimeMobileSessionProjectionHost } from './runtime-mobile-session-projection-contract'
|
|
import {
|
|
getLatestAgentCandidateTitle,
|
|
terminalTitleBlocksExplicitAgentStatus
|
|
} from './runtime-worktree-status-projection'
|
|
|
|
export function projectRuntimeMobileSessionTabs(
|
|
snapshot: RuntimeMobileSessionTabsSnapshot,
|
|
host: RuntimeMobileSessionProjectionHost
|
|
): RuntimeMobileSessionTabsResult {
|
|
const tabs: RuntimeMobileSessionClientTab[] = []
|
|
const liveBrowserTabsByPageId = host.getLiveBrowserTabs(snapshot.worktree)
|
|
// Production reads hook rows by pane; the snapshot fallback remains for tests
|
|
// and embedders that have not adopted the narrow getter.
|
|
let hookRowsByPaneKey: Map<string, AgentStatusIpcPayload[]> | null = null
|
|
const hookRowsForPane = new Map<string, AgentStatusIpcPayload[]>()
|
|
const getHookRowsForPane = (paneKey: string): AgentStatusIpcPayload[] => {
|
|
const cached = hookRowsForPane.get(paneKey)
|
|
if (cached) {
|
|
return cached
|
|
}
|
|
const direct = host.getProviderSessionRows(paneKey)
|
|
if (direct) {
|
|
hookRowsForPane.set(paneKey, direct)
|
|
return direct
|
|
}
|
|
hookRowsByPaneKey ??= indexAgentStatusRowsByPaneKey(host.getProviderSessionSnapshot())
|
|
const rows = hookRowsByPaneKey.get(paneKey) ?? []
|
|
hookRowsForPane.set(paneKey, rows)
|
|
return rows
|
|
}
|
|
let statusRowsByPaneKey: Map<string, AgentStatusIpcPayload[]> | null = null
|
|
let statusRowsByTerminalHandle: Map<string, AgentStatusIpcPayload[]> | null = null
|
|
const getStatusRows = (
|
|
paneKey: string,
|
|
terminalHandle: string | null
|
|
): AgentStatusIpcPayload[] => {
|
|
if (!statusRowsByPaneKey || !statusRowsByTerminalHandle) {
|
|
statusRowsByPaneKey = new Map()
|
|
statusRowsByTerminalHandle = new Map()
|
|
for (const row of host.getStatusSnapshot()) {
|
|
const paneRows = statusRowsByPaneKey.get(row.paneKey)
|
|
if (paneRows) {
|
|
paneRows.push(row)
|
|
} else {
|
|
statusRowsByPaneKey.set(row.paneKey, [row])
|
|
}
|
|
if (row.terminalHandle) {
|
|
const handleRows = statusRowsByTerminalHandle.get(row.terminalHandle)
|
|
if (handleRows) {
|
|
handleRows.push(row)
|
|
} else {
|
|
statusRowsByTerminalHandle.set(row.terminalHandle, [row])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
const paneRows = statusRowsByPaneKey.get(paneKey) ?? []
|
|
if (!terminalHandle) {
|
|
return paneRows
|
|
}
|
|
const handleRows = statusRowsByTerminalHandle.get(terminalHandle) ?? []
|
|
if (paneRows.length === 0) {
|
|
return handleRows
|
|
}
|
|
return [...paneRows, ...handleRows.filter((row) => !paneRows.includes(row))]
|
|
}
|
|
// Why: a live PTY backs one surface; claim each once so two leaves resolving to it can't emit duplicate React keys and crash the client.
|
|
const claimedLivePtyIds = new Set<string>()
|
|
for (const tab of snapshot.tabs) {
|
|
if (tab.type === 'browser') {
|
|
const liveTab = tab.browserPageId ? liveBrowserTabsByPageId.get(tab.browserPageId) : undefined
|
|
if (!liveTab) {
|
|
continue
|
|
}
|
|
// Why: renderer snapshots lag BrowserView teardown/process swaps; only surface pages the browser bridge can still route to.
|
|
tabs.push({
|
|
...tab,
|
|
title: liveTab.title || tab.title,
|
|
url: liveTab.url || tab.url,
|
|
// Why: bridge "active" means active BrowserView/webContents, not active Orca tab; preserve the renderer's session focus.
|
|
isActive: tab.isActive
|
|
})
|
|
continue
|
|
}
|
|
if (tab.type === 'markdown' || tab.type === 'file') {
|
|
tabs.push(tab)
|
|
continue
|
|
}
|
|
if (tab.type === 'agent-session') {
|
|
tabs.push(tab)
|
|
continue
|
|
}
|
|
const syncedTab = host.tabs.get(tab.parentTabId)
|
|
const leaf = host.leaves.get(host.getLeafKey(tab.parentTabId, tab.leafId)) ?? null
|
|
const liveLeaf = leaf?.ptyId && leaf.connected ? leaf : null
|
|
const liveLeafPtyId = liveLeaf?.ptyId ?? null
|
|
const liveLeafPty = liveLeafPtyId ? (host.ptysById.get(liveLeafPtyId) ?? null) : null
|
|
const pty = liveLeaf
|
|
? null
|
|
: host.findPty(snapshot.worktree, tab, {
|
|
allowWorktreeOnlyMatch: !snapshot.publicationEpoch.startsWith('headless')
|
|
})
|
|
const livePty = pty?.connected ? pty : null
|
|
// Why: enforce one-live-PTY-per-tab; drop a later tab resolving to an already-claimed PTY so no two tabs share a handle.
|
|
const resolvedLivePtyId = liveLeafPtyId ?? livePty?.ptyId ?? null
|
|
if (resolvedLivePtyId !== null) {
|
|
if (claimedLivePtyIds.has(resolvedLivePtyId)) {
|
|
continue
|
|
}
|
|
claimedLivePtyIds.add(resolvedLivePtyId)
|
|
}
|
|
const legacyPaneId = /^pane:(\d+)$/.exec(tab.leafId)?.[1] ?? null
|
|
const paneKey = isTerminalLeafId(tab.leafId)
|
|
? makePaneKey(tab.parentTabId, tab.leafId)
|
|
: `${tab.parentTabId}:${legacyPaneId ?? tab.leafId}`
|
|
const mobileStatusPty = livePty ?? pty
|
|
// Why: headless hooks live in main's status store; reuse this lookup
|
|
// for both title ownership and status publication so the two cannot diverge.
|
|
const retainedAgentStatus = tab.agentStatus
|
|
? null
|
|
: host.getRetainedStatus(paneKey, liveLeafPty ?? mobileStatusPty, tab, getStatusRows)
|
|
const hookAgentStatus = tab.agentStatus
|
|
? selectRuntimeHookAgentRowForPane(getHookRowsForPane(paneKey))
|
|
: null
|
|
// Why not tab.ptyId: findPtyForMobileTerminalTab already rejected it when it returned
|
|
// null, because persisted ids can collide with an unrelated pane after restart — reading
|
|
// that pane's tracker would publish its title here, ahead of every other source.
|
|
const trackerOnlyTitle = host.getTrackedTitle(liveLeafPtyId ?? pty?.ptyId ?? null)
|
|
const leafTitle = leaf
|
|
? getLatestAgentCandidateTitle(
|
|
{ title: leaf.paneTitle, updatedAt: leaf.paneTitleUpdatedAt },
|
|
{ title: leaf.lastOscTitle, updatedAt: leaf.lastOscTitleAt }
|
|
)
|
|
: null
|
|
const ptyTitle = pty
|
|
? getLatestAgentCandidateTitle(
|
|
{ title: pty.title, updatedAt: pty.titleUpdatedAt },
|
|
{ title: pty.lastOscTitle, updatedAt: pty.lastOscTitleAt }
|
|
)
|
|
: null
|
|
// Renderer omission is authoritative: PTY launch provenance outlives agent exit.
|
|
const launchAgent = tab.launchAgent ?? null
|
|
const launchOwnerAgent = launchAgent ?? liveLeafPty?.launchAgent ?? pty?.launchAgent ?? null
|
|
// Why: a retained OMP hook stays stable while wrapper foreground reads can report Pi.
|
|
const ownerRecord = resolvePaneAgentOwnerRecord({
|
|
launchAgent: launchOwnerAgent,
|
|
hookAgent:
|
|
tab.agentStatus?.agentType ??
|
|
hookAgentStatus?.agentType ??
|
|
retainedAgentStatus?.payload.agentType ??
|
|
null
|
|
})
|
|
const ownerAgent =
|
|
ownerRecord?.agent ?? liveLeafPty?.foregroundAgent ?? pty?.foregroundAgent ?? null
|
|
const ownerOptions = { ownerIsLaunch: ownerRecord?.ownerIsLaunch === true }
|
|
const title = normalizeCompatibleAgentTitleForOwner(
|
|
trackerOnlyTitle ?? leafTitle ?? ptyTitle ?? syncedTab?.title ?? tab.title,
|
|
ownerAgent,
|
|
ownerOptions
|
|
)
|
|
const liveTitleEvidence = leafTitle ?? ptyTitle
|
|
// Why: renderer status can precede hook session identity, leaving native chat with no transcript address.
|
|
const rendererStatusAgent =
|
|
resolveCompatibleAgentTypeForOwner(tab.agentStatus?.agentType, ownerAgent, ownerOptions) ??
|
|
ownerAgent ??
|
|
undefined
|
|
const hookSessionAgent = resolveCompatibleAgentTypeForOwner(
|
|
hookAgentStatus?.providerSessionAgentType,
|
|
ownerAgent,
|
|
ownerOptions
|
|
)
|
|
const hookSessionMatchesRenderer =
|
|
!rendererStatusAgent || !hookSessionAgent || rendererStatusAgent === hookSessionAgent
|
|
const hookProviderSession =
|
|
hookAgentStatus?.providerSession &&
|
|
hookSessionMatchesRenderer &&
|
|
(!tab.agentStatus?.providerSession ||
|
|
(hookAgentStatus.providerSessionReceivedAt ?? -1) >= tab.agentStatus.updatedAt)
|
|
? hookAgentStatus.providerSession
|
|
: tab.agentStatus?.providerSession
|
|
const statusPty = liveLeafPty ?? mobileStatusPty
|
|
const normalizedTabAgentStatus = renewRuntimeMobileAgentStatusFromPtyTitle(
|
|
tab.agentStatus
|
|
? normalizeCompatibleAgentStatusEntryForOwner(
|
|
{
|
|
...tab.agentStatus,
|
|
...(hookProviderSession ? { providerSession: hookProviderSession } : {})
|
|
},
|
|
ownerAgent,
|
|
ownerOptions
|
|
)
|
|
: null,
|
|
statusPty,
|
|
{ preserveQuestionUnderShellTitle: true }
|
|
)
|
|
// Why: keep rich status on a live prompt/tool, or interactivePrompt is lost under a non-agent title.
|
|
const hasLiveAgentSignal =
|
|
normalizedTabAgentStatus?.interactivePrompt != null ||
|
|
normalizedTabAgentStatus?.toolName != null
|
|
// Why: only shell/management evidence proves the agent released the pane
|
|
// (same predicate as the terminal-status API). A merely neutral live title
|
|
// — 'Terminal', an editor, a cwd — proves nothing, and treating it as
|
|
// completion published a synthetic `done` that fought the client's own
|
|
// live status on every republication.
|
|
const keepFullAgentStatus =
|
|
normalizedTabAgentStatus &&
|
|
(!terminalTitleBlocksExplicitAgentStatus(liveTitleEvidence) || hasLiveAgentSignal)
|
|
const agentStatus = keepFullAgentStatus
|
|
? { agentStatus: normalizedTabAgentStatus }
|
|
: // Why: idle live title → drop stale "working" (no spinner) but keep agent identity so native chat can still address the transcript.
|
|
normalizedTabAgentStatus?.agentType != null
|
|
? {
|
|
agentStatus: {
|
|
state: 'done' as const,
|
|
prompt: '',
|
|
updatedAt: statusPty?.lastOscTitleEpochMs ?? normalizedTabAgentStatus.updatedAt,
|
|
stateStartedAt:
|
|
statusPty?.lastAgentStatusStartedAtEpochMs ??
|
|
normalizedTabAgentStatus.stateStartedAt,
|
|
paneKey: normalizedTabAgentStatus.paneKey,
|
|
stateHistory: [],
|
|
agentType: normalizedTabAgentStatus.agentType,
|
|
...(normalizedTabAgentStatus.providerSession
|
|
? { providerSession: normalizedTabAgentStatus.providerSession }
|
|
: {})
|
|
}
|
|
}
|
|
: null
|
|
// Why: web/mobile clients hold handles across renderer graph syncs; leaf handles are epoch-bound but PTY handles stay streamable.
|
|
const terminalHandle = liveLeafPtyId
|
|
? host.issuePtyHandle(
|
|
host.recordPty(liveLeafPtyId, snapshot.worktree, {
|
|
tabId: tab.parentTabId,
|
|
paneKey,
|
|
connected: true
|
|
})
|
|
)
|
|
: livePty
|
|
? host.issuePtyHandle(livePty)
|
|
: null
|
|
const projectedAgentStatus =
|
|
agentStatus ??
|
|
host.buildPtyStatus(
|
|
mobileStatusPty,
|
|
tab,
|
|
terminalHandle,
|
|
retainedAgentStatus,
|
|
getHookRowsForPane
|
|
)
|
|
const projectedStatusEntry = projectedAgentStatus.agentStatus as
|
|
| (AgentStatusEntry & { turnCompletedAt?: number })
|
|
| undefined
|
|
const { turnCompletedAt: projectedTurnCompletedAt, ...clientStatusFields } =
|
|
projectedStatusEntry ?? {}
|
|
const rawTurnCompletedAt =
|
|
hookAgentStatus?.live?.payload.turnCompletedAt ??
|
|
selectRuntimeHookAgentRowForPane(getHookRowsForPane(paneKey)).live?.payload.turnCompletedAt ??
|
|
projectedTurnCompletedAt
|
|
const turnCompletedAt =
|
|
typeof rawTurnCompletedAt === 'number' && Number.isFinite(rawTurnCompletedAt)
|
|
? rawTurnCompletedAt
|
|
: undefined
|
|
const clientAgentStatus: { agentStatus?: AgentStatusEntry } = projectedStatusEntry
|
|
? { agentStatus: clientStatusFields as AgentStatusEntry }
|
|
: {}
|
|
tabs.push({
|
|
type: 'terminal',
|
|
id: tab.id,
|
|
parentTabId: tab.parentTabId,
|
|
leafId: tab.leafId,
|
|
title,
|
|
...(tab.ptyId ? { ptyId: tab.ptyId } : {}),
|
|
...(tab.terminalTheme ? { terminalTheme: tab.terminalTheme } : {}),
|
|
...(launchAgent ? { launchAgent } : {}),
|
|
...clientAgentStatus,
|
|
...(turnCompletedAt !== undefined ? { turnCompletedAt } : {}),
|
|
...(tab.parentLayout ? { parentLayout: tab.parentLayout } : {}),
|
|
...(tab.startupCwd ? { startupCwd: tab.startupCwd } : {}),
|
|
...(tab.color != null ? { color: tab.color } : {}),
|
|
...(tab.isPinned ? { isPinned: true } : {}),
|
|
...(tab.viewMode ? { viewMode: tab.viewMode } : {}),
|
|
...(tab.launchDraft ? { launchDraft: tab.launchDraft } : {}),
|
|
...(tab.launchDraftCreatedAt !== undefined
|
|
? { launchDraftCreatedAt: tab.launchDraftCreatedAt }
|
|
: {}),
|
|
isActive: tab.isActive,
|
|
...(terminalHandle
|
|
? { status: 'ready' as const, terminal: terminalHandle }
|
|
: { status: 'pending-handle' as const, terminal: null })
|
|
})
|
|
}
|
|
return finalizeRuntimeMobileSessionTabsResult({ snapshot, tabs }, host)
|
|
}
|