mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(ssh): a managed server restart no longer marks still-running agents done (#29604)
* fix(runtime): keep a restored agent turn unverifiable instead of done after a server restart * fix(runtime): retire restored turns by the synced leaf's title clock; no Stop for unverifiable mobile turns * fix(runtime): capability-gate restored agent rows so older clients keep the legacy done projection * fix(runtime): retire restored turns only on shell titles; restored rows never pierce live client status * test(web): the browser client advertises the restored agent status capability
This commit is contained in:
@@ -56,8 +56,11 @@ export function useMobileNativeChatActiveResolution(args: {
|
||||
const activeChatStructured =
|
||||
activeChatResolution != null && activeSessionTab?.type === 'agent-session'
|
||||
const activeTabStatus = activeSessionTab?.agentStatus
|
||||
// Why: a turn restored across a host restart is unverifiable, so it must not offer Stop.
|
||||
const activeTabAgentWorking =
|
||||
activeTabStatus?.state === 'working' && activeTabStatus.workingMode !== 'monitoring'
|
||||
activeTabStatus?.state === 'working' &&
|
||||
activeTabStatus.workingMode !== 'monitoring' &&
|
||||
activeTabStatus.restoredUnconfirmed !== true
|
||||
const nativeChatStatus = activeChatResolution && !activeChatStructured ? activeTabStatus : null
|
||||
const routeKey = `${hostId}\0${worktreeId}\0${activeSessionTabId ?? ''}`
|
||||
const streamIdentity = `${routeKey}\0${activeChatSessionId ?? ''}\0${activeHandleRef.current ?? ''}`
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY,
|
||||
AGENT_STATUS_RESTORED_UNCONFIRMED_RUNTIME_CAPABILITY,
|
||||
AGENT_SESSION_TURN_ITEM_CAPABILITY,
|
||||
CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
|
||||
PI_STRUCTURED_DIALOGS_RUNTIME_CAPABILITY,
|
||||
@@ -37,7 +38,9 @@ export const MOBILE_RUNTIME_CLIENT_CAPABILITIES = remoteRuntimeClientCapabilitie
|
||||
// Reads a listed launch tab with no terminal yet as not started, so the host may show it early.
|
||||
AGENT_LAUNCH_UNSTARTED_TAB_CLIENT_CAPABILITY,
|
||||
// Reads `agent_launch_tab_closed` (a user closed its tab, which stopped it) as a definite answer.
|
||||
AGENT_LAUNCH_TAB_CLOSED_CLIENT_CAPABILITY
|
||||
AGENT_LAUNCH_TAB_CLOSED_CLIENT_CAPABILITY,
|
||||
// Native chat offers no Stop for a turn the host restored but could not confirm.
|
||||
AGENT_STATUS_RESTORED_UNCONFIRMED_RUNTIME_CAPABILITY
|
||||
])
|
||||
|
||||
export const MOBILE_RUNTIME_CLIENT_CAPABILITY_UPDATE_METHOD =
|
||||
|
||||
@@ -229,13 +229,55 @@ describe('headless hook agent-status projection (#11761)', () => {
|
||||
// #12346: a row hydrated from last-status.json may describe a turn that ended while
|
||||
// no receiver was up, so its recent `receivedAt` proves nothing. Publishing it would
|
||||
// resurrect the question card on every restart, with no agent left to answer it.
|
||||
it('refuses a hydrated unconfirmed row while keeping its resume identity', async () => {
|
||||
it('refuses a hydrated unconfirmed row as live while keeping its resume identity', async () => {
|
||||
const agentStatus = await projectAgentStatus([hookRow({ restoredUnconfirmed: true })])
|
||||
|
||||
expect(agentStatus).toEqual(
|
||||
expect.objectContaining({ state: 'done', prompt: '', providerSession: PROVIDER_SESSION })
|
||||
expect.objectContaining({
|
||||
state: 'waiting',
|
||||
restoredUnconfirmed: true,
|
||||
providerSession: PROVIDER_SESSION
|
||||
})
|
||||
)
|
||||
expect(agentStatus).not.toHaveProperty('interactivePrompt')
|
||||
expect(agentStatus).not.toHaveProperty('toolName')
|
||||
})
|
||||
|
||||
// A managed server restart re-hydrates last-status.json while the daemon keeps the agent
|
||||
// running: the turn is unverifiable, so it must not be published as finished.
|
||||
it('keeps a restored working turn unverifiable instead of done', async () => {
|
||||
const receivedAt = Date.now() - 60_000
|
||||
const agentStatus = await projectAgentStatus([
|
||||
hookRow({
|
||||
state: 'working',
|
||||
prompt: 'refactor the parser',
|
||||
toolName: undefined,
|
||||
interactivePrompt: undefined,
|
||||
receivedAt,
|
||||
stateStartedAt: receivedAt - 5_000,
|
||||
restoredUnconfirmed: true
|
||||
})
|
||||
])
|
||||
|
||||
expect(agentStatus).toEqual(
|
||||
expect.objectContaining({
|
||||
state: 'working',
|
||||
prompt: 'refactor the parser',
|
||||
restoredUnconfirmed: true,
|
||||
updatedAt: receivedAt,
|
||||
stateStartedAt: receivedAt - 5_000
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('lets a shell title retire a restored turn as done', async () => {
|
||||
const agentStatus = await projectAgentStatus(
|
||||
[hookRow({ state: 'working', interactivePrompt: undefined, restoredUnconfirmed: true })],
|
||||
(runtime) => observePaneTitle(runtime, 'bash')
|
||||
)
|
||||
|
||||
expect(agentStatus === undefined || agentStatus.state === 'done').toBe(true)
|
||||
expect(agentStatus).not.toHaveProperty('restoredUnconfirmed')
|
||||
})
|
||||
|
||||
// #7970: a retained OSC 9999 row is the pane's own report and keeps precedence.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
|
||||
AGENT_STATUS_RESTORED_UNCONFIRMED_RUNTIME_CAPABILITY,
|
||||
CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
|
||||
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
|
||||
} from '../../../../shared/protocol-version'
|
||||
@@ -41,7 +42,56 @@ function makeSnapshot(sessionBoundary: boolean): RuntimeMobileSessionTabsSnapsho
|
||||
}
|
||||
}
|
||||
|
||||
function makeRestoredSnapshot(): RuntimeMobileSessionTabsSnapshot {
|
||||
const snapshot = makeSnapshot(false)
|
||||
const tab = snapshot.tabs[0]!
|
||||
return {
|
||||
...snapshot,
|
||||
tabs: [
|
||||
tab.type === 'terminal'
|
||||
? {
|
||||
...tab,
|
||||
agentStatus: {
|
||||
...tab.agentStatus!,
|
||||
state: 'working',
|
||||
prompt: 'refactor the parser',
|
||||
restoredUnconfirmed: true
|
||||
}
|
||||
}
|
||||
: tab
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
describe('projectSessionTabAgentStatus', () => {
|
||||
// A client that cannot read `restoredUnconfirmed` would take the last known state as live.
|
||||
it.each(['mobile', 'runtime'] as const)(
|
||||
'keeps the legacy done projection for a %s client without the restored capability',
|
||||
(clientKind) => {
|
||||
const projected = projectSessionTabAgentStatus(makeRestoredSnapshot(), clientKind, [])
|
||||
const tab = projected.tabs[0]
|
||||
|
||||
expect(tab?.type === 'terminal' ? tab.agentStatus : null).toEqual(
|
||||
expect.objectContaining({ state: 'done', prompt: '' })
|
||||
)
|
||||
expect(tab?.type === 'terminal' ? tab.agentStatus : null).not.toHaveProperty(
|
||||
'restoredUnconfirmed'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('publishes a restored turn as unverifiable to a client that reads it', () => {
|
||||
const projected = projectSessionTabAgentStatus(makeRestoredSnapshot(), 'runtime', [
|
||||
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
|
||||
AGENT_STATUS_RESTORED_UNCONFIRMED_RUNTIME_CAPABILITY
|
||||
])
|
||||
const tab = projected.tabs[0]
|
||||
|
||||
expect(tab?.type === 'terminal' ? tab.agentStatus : null).toEqual(
|
||||
expect.objectContaining({ state: 'working', restoredUnconfirmed: true })
|
||||
)
|
||||
})
|
||||
|
||||
it('projects structured tabs and dangling group focus out of old clients', () => {
|
||||
const snapshot: RuntimeMobileSessionTabsSnapshot = {
|
||||
...makeSnapshot(false),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
|
||||
AGENT_STATUS_RESTORED_UNCONFIRMED_RUNTIME_CAPABILITY,
|
||||
type RuntimeCapability
|
||||
} from '../../../../shared/protocol-version'
|
||||
import type {
|
||||
@@ -72,6 +73,12 @@ export function projectSessionTabAgentStatus<TPayload extends SessionTabsPayload
|
||||
)
|
||||
}
|
||||
}
|
||||
if (
|
||||
clientKind !== undefined &&
|
||||
!clientCapabilities?.includes(AGENT_STATUS_RESTORED_UNCONFIRMED_RUNTIME_CAPABILITY)
|
||||
) {
|
||||
projected = projectRestoredAgentStatusAsLegacyDone(projected)
|
||||
}
|
||||
// Why: only paired runtimes have legacy `done` completion side effects; mobile must keep its row without changing the exact v2 auth shape.
|
||||
if (
|
||||
clientKind !== 'runtime' ||
|
||||
@@ -92,6 +99,23 @@ export function projectSessionTabAgentStatus<TPayload extends SessionTabsPayload
|
||||
return changed ? ({ ...projected, tabs } as TPayload) : projected
|
||||
}
|
||||
|
||||
/** What hosts published for a restored turn before clients could read it as unverifiable. */
|
||||
function projectRestoredAgentStatusAsLegacyDone<TPayload extends SessionTabsPayload>(
|
||||
payload: TPayload
|
||||
): TPayload {
|
||||
let changed = false
|
||||
const tabs = payload.tabs.map((tab) => {
|
||||
if (tab.type !== 'terminal' || tab.agentStatus?.restoredUnconfirmed !== true) {
|
||||
return tab
|
||||
}
|
||||
changed = true
|
||||
const { restoredUnconfirmed: _restored, ...legacyStatus } = tab.agentStatus
|
||||
return { ...tab, agentStatus: { ...legacyStatus, state: 'done' as const, prompt: '' } }
|
||||
})
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only terminal tabs' agentStatus changed; every other TPayload field is spread through unchanged.
|
||||
return changed ? ({ ...payload, tabs } as TPayload) : payload
|
||||
}
|
||||
|
||||
function projectUnsupportedAgentSessionTabTitles<TPayload extends SessionTabsPayload>(
|
||||
payload: TPayload,
|
||||
args: {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentStatusIpcPayload } from '../../shared/agent-status-types'
|
||||
import type { RuntimeMobileSessionTerminalTab } from '../../shared/runtime-types'
|
||||
import type { RuntimeAgentRowSnapshot } from './runtime-hook-agent-row-selection'
|
||||
import { buildRuntimeMobileAgentStatus } from './runtime-mobile-agent-status-builder'
|
||||
import type { RuntimeLeafRecord } from './runtime-terminal-state-records'
|
||||
|
||||
const PROVIDER_SESSION = { key: 'session_id' as const, id: 'session-1' }
|
||||
const TAB: RuntimeMobileSessionTerminalTab = {
|
||||
@@ -37,3 +39,111 @@ describe('mobile agent status builder', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// A synced leaf projects with no PTY record of its own, so a shell title it painted after a
|
||||
// restored turn must still retire that turn through the leaf's PTY title clock.
|
||||
const TAB_ID = 'tab-1'
|
||||
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
|
||||
const PANE_KEY = `${TAB_ID}:${LEAF_ID}`
|
||||
const PTY_ID = 'pty-1'
|
||||
|
||||
const LEAF_TAB: RuntimeMobileSessionTerminalTab = {
|
||||
type: 'terminal',
|
||||
id: `${TAB_ID}::${LEAF_ID}`,
|
||||
title: 'Terminal 1',
|
||||
parentTabId: TAB_ID,
|
||||
leafId: LEAF_ID,
|
||||
isActive: true
|
||||
}
|
||||
|
||||
function leafWithTitle(lastOscTitle: string | null): RuntimeLeafRecord {
|
||||
return {
|
||||
tabId: TAB_ID,
|
||||
worktreeId: 'wt-1',
|
||||
leafId: LEAF_ID,
|
||||
paneRuntimeId: 1,
|
||||
ptyId: PTY_ID,
|
||||
paneTitle: null,
|
||||
title: null,
|
||||
ptyGeneration: 1,
|
||||
connected: true,
|
||||
writable: true,
|
||||
lastOutputAt: null,
|
||||
lastExitCode: null,
|
||||
lastExitCause: null,
|
||||
lastAgentStatus: null,
|
||||
lastAgentStatusObservedLive: false,
|
||||
lastOscTitle,
|
||||
lastOscTitleAt: lastOscTitle ? 1 : null,
|
||||
paneTitleUpdatedAt: null,
|
||||
tailBuffer: [],
|
||||
tailTranscriptBuffer: [],
|
||||
tailTranscriptChars: 0,
|
||||
tailPartialLine: '',
|
||||
tailPendingAnsi: '',
|
||||
tailRedrawCursor: null,
|
||||
tailTruncated: false,
|
||||
tailLinesTotal: 0,
|
||||
preview: '',
|
||||
waitBlockedAt: null
|
||||
}
|
||||
}
|
||||
|
||||
function restoredRow(receivedAt: number): AgentStatusIpcPayload {
|
||||
return {
|
||||
paneKey: PANE_KEY,
|
||||
state: 'working',
|
||||
prompt: 'refactor the parser',
|
||||
agentType: 'claude',
|
||||
connectionId: null,
|
||||
receivedAt,
|
||||
stateStartedAt: receivedAt,
|
||||
tabId: TAB_ID,
|
||||
worktreeId: 'wt-1',
|
||||
restoredUnconfirmed: true
|
||||
}
|
||||
}
|
||||
|
||||
function project(leaf: RuntimeLeafRecord, titleEpochMs: number | null, receivedAt: number) {
|
||||
return buildRuntimeMobileAgentStatus(
|
||||
null,
|
||||
LEAF_TAB,
|
||||
null,
|
||||
null,
|
||||
() => [restoredRow(receivedAt)],
|
||||
{
|
||||
getPaneKey: () => PANE_KEY,
|
||||
getLeaf: () => leaf,
|
||||
getTrackedTitle: () => null,
|
||||
getPtyTitleObservedAtEpochMs: (ptyId) => (ptyId === PTY_ID ? titleEpochMs : null)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
describe('restored turns on a synced leaf', () => {
|
||||
it('stays unverifiable when only a seeded (unclocked) shell title is known', () => {
|
||||
const receivedAt = Date.now() - 60_000
|
||||
const result = project(leafWithTitle('bash'), null, receivedAt)
|
||||
|
||||
expect(result.agentStatus).toEqual(
|
||||
expect.objectContaining({ state: 'working', restoredUnconfirmed: true })
|
||||
)
|
||||
})
|
||||
|
||||
it('stays unverifiable under a newer title that is not a shell', () => {
|
||||
const receivedAt = Date.now() - 60_000
|
||||
const result = project(leafWithTitle('vim'), receivedAt + 1_000, receivedAt)
|
||||
|
||||
expect(result.agentStatus).toEqual(
|
||||
expect.objectContaining({ state: 'working', restoredUnconfirmed: true })
|
||||
)
|
||||
})
|
||||
|
||||
it('retires to done once the leaf PTY paints a newer shell title', () => {
|
||||
const receivedAt = Date.now() - 60_000
|
||||
const result = project(leafWithTitle('bash'), receivedAt + 1_000, receivedAt)
|
||||
|
||||
expect(result.agentStatus?.state).toBe('done')
|
||||
expect(result.agentStatus).not.toHaveProperty('restoredUnconfirmed')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,13 +15,16 @@ import type { RuntimeAgentRowSnapshot } from './runtime-worktree-agent-rows'
|
||||
import {
|
||||
classifyAgentTitle,
|
||||
getLatestAgentCandidateTitle,
|
||||
getLatestPtyTitle
|
||||
getLatestPtyTitle,
|
||||
terminalTitleBlocksExplicitAgentStatus
|
||||
} from './runtime-worktree-status-projection'
|
||||
|
||||
type RuntimeMobileAgentStatusHost = {
|
||||
getPaneKey(tab: RuntimeMobileSessionTerminalTab): string
|
||||
getLeaf(tab: RuntimeMobileSessionTerminalTab): RuntimeLeafRecord | null
|
||||
getTrackedTitle(ptyId: string | null): string | null
|
||||
/** Wall clock of the PTY's latest live OSC title; seeded restore titles leave it unset. */
|
||||
getPtyTitleObservedAtEpochMs?(ptyId: string | null): number | null
|
||||
}
|
||||
|
||||
export function buildRuntimeMobileAgentStatus(
|
||||
@@ -127,6 +130,41 @@ export function buildRuntimeMobileAgentStatus(
|
||||
return { agentStatus: renewedStatus }
|
||||
}
|
||||
}
|
||||
// Why: a turn restored across a server restart is unverifiable, not finished; a synthetic
|
||||
// `done` told clients a running agent had stopped. Keep its own date (so it cannot outrank a
|
||||
// client's row) and drop the question fields (#12346). Only a later shell title retires it.
|
||||
const shellTitleRetiredRestored =
|
||||
hookRow.restored !== null &&
|
||||
terminalTitleBlocksExplicitAgentStatus(ptyTitle) &&
|
||||
(pty?.lastOscTitleEpochMs ?? host.getPtyTitleObservedAtEpochMs?.(leaf?.ptyId ?? null) ?? 0) >=
|
||||
hookRow.restored.updatedAt
|
||||
const restored = !pty?.lastAgentStatus && !shellTitleRetiredRestored ? hookRow.restored : null
|
||||
if (restored) {
|
||||
const restoredAgentType = ownerAgent ?? restored.payload.agentType
|
||||
return {
|
||||
agentStatus: normalizeCompatibleAgentStatusEntryForOwner(
|
||||
{
|
||||
state: restored.payload.state,
|
||||
prompt: restored.payload.prompt,
|
||||
updatedAt: restored.updatedAt,
|
||||
stateStartedAt: restored.stateStartedAt,
|
||||
restoredUnconfirmed: true,
|
||||
paneKey,
|
||||
...(terminalHandle ? { terminalHandle } : {}),
|
||||
...(restoredAgentType ? { agentType: restoredAgentType } : {}),
|
||||
...((pty?.worktreeId ?? restored.worktreeId)
|
||||
? { worktreeId: pty?.worktreeId ?? restored.worktreeId }
|
||||
: {}),
|
||||
tabId: tab.parentTabId,
|
||||
terminalTitle,
|
||||
stateHistory: [],
|
||||
...providerSession
|
||||
},
|
||||
ownerAgent,
|
||||
ownerOptions
|
||||
)
|
||||
}
|
||||
}
|
||||
// Last resort: the pane's hook evidence is identity only (resume rows, stale
|
||||
// rows, or a row the freshness gate rejected). `done` is the honest
|
||||
// projection — and it is what retires the card once the agent exits.
|
||||
|
||||
@@ -120,6 +120,8 @@ export type RuntimeHookAgentRowLookup = {
|
||||
agentType: string | null
|
||||
agentIsLive: boolean
|
||||
live: HookLiveAgentRow | null
|
||||
/** Latest fresh nonterminal row hydrated across a host restart: unverifiable, never live. */
|
||||
restored: HookLiveAgentRow | null
|
||||
}
|
||||
|
||||
/** Resume identity stays valid until relaunch; ownership and live status remain freshness-bounded. */
|
||||
@@ -129,6 +131,7 @@ export function selectRuntimeHookAgentRowForPane(
|
||||
let session: AgentStatusIpcPayload | null = null
|
||||
let agent: AgentStatusIpcPayload | null = null
|
||||
let live: AgentStatusIpcPayload | null = null
|
||||
let restored: AgentStatusIpcPayload | null = null
|
||||
const freshAfter = Date.now() - AGENT_STATUS_STALE_AFTER_MS
|
||||
for (const entry of rows) {
|
||||
if (entry.providerSession && (!session || entry.receivedAt > session.receivedAt)) {
|
||||
@@ -152,6 +155,19 @@ export function selectRuntimeHookAgentRowForPane(
|
||||
) {
|
||||
live = entry
|
||||
}
|
||||
if (
|
||||
entry.providerSessionOnly !== true &&
|
||||
entry.restoredUnconfirmed === true &&
|
||||
entry.state !== 'done' &&
|
||||
(entry.evidenceObservedAt ?? entry.receivedAt) >= freshAfter &&
|
||||
(!restored || entry.receivedAt > restored.receivedAt)
|
||||
) {
|
||||
restored = entry
|
||||
}
|
||||
}
|
||||
// A confirmed row at least as new as the restored one supersedes it.
|
||||
if (restored && live && live.receivedAt >= restored.receivedAt) {
|
||||
restored = null
|
||||
}
|
||||
return {
|
||||
providerSession: session?.providerSession ?? null,
|
||||
@@ -159,17 +175,18 @@ export function selectRuntimeHookAgentRowForPane(
|
||||
providerSessionReceivedAt: session?.receivedAt ?? null,
|
||||
agentType: agent?.agentType ?? null,
|
||||
agentIsLive: agent != null && agent.state !== 'done',
|
||||
live: live
|
||||
? {
|
||||
payload: pickParsedAgentStatusPayload(live),
|
||||
updatedAt: live.receivedAt,
|
||||
...(live.evidenceObservedAt !== undefined
|
||||
? { evidenceObservedAt: live.evidenceObservedAt }
|
||||
: {}),
|
||||
stateStartedAt: live.stateStartedAt ?? live.receivedAt,
|
||||
...(live.worktreeId ? { worktreeId: live.worktreeId } : {})
|
||||
}
|
||||
: null
|
||||
live: live ? toHookLiveAgentRow(live) : null,
|
||||
restored: restored ? toHookLiveAgentRow(restored) : null
|
||||
}
|
||||
}
|
||||
|
||||
function toHookLiveAgentRow(row: AgentStatusIpcPayload): HookLiveAgentRow {
|
||||
return {
|
||||
payload: pickParsedAgentStatusPayload(row),
|
||||
updatedAt: row.receivedAt,
|
||||
...(row.evidenceObservedAt !== undefined ? { evidenceObservedAt: row.evidenceObservedAt } : {}),
|
||||
stateStartedAt: row.stateStartedAt ?? row.receivedAt,
|
||||
...(row.worktreeId ? { worktreeId: row.worktreeId } : {})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -148,7 +148,9 @@ export abstract class OrcaRuntimeWithPruneMobileSessionTabGroupLayout extends Or
|
||||
const leaf = this.leaves.get(this.getLeafKey(candidate.parentTabId, candidate.leafId))
|
||||
return leaf ? this.getLeafDisplayRecord(leaf) : null
|
||||
},
|
||||
getTrackedTitle: (ptyId) => this.getUnpersistedTrackedTitleForPty(ptyId)
|
||||
getTrackedTitle: (ptyId) => this.getUnpersistedTrackedTitleForPty(ptyId),
|
||||
getPtyTitleObservedAtEpochMs: (ptyId) =>
|
||||
ptyId ? (this.ptysById.get(ptyId)?.lastOscTitleEpochMs ?? null) : null
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -513,6 +513,30 @@ describe('remote-paired pane: host snapshot mirror vs client byte-derived status
|
||||
expect(entry?.interactivePrompt).toBe('Allow Bash(rm -rf)?')
|
||||
})
|
||||
|
||||
it('keeps live client status over a restored, unconfirmed host block', () => {
|
||||
const store = seedPairedClientStore()
|
||||
replayClientOscWorking(store, T0 + 500)
|
||||
|
||||
// Host clock ahead of the client: only the unconfirmed flag can keep this row out.
|
||||
const restoredAt = T0 + 60_000
|
||||
applyHostSnapshot(
|
||||
store,
|
||||
makeHostSnapshot({
|
||||
snapshotVersion: 2,
|
||||
hostNow: restoredAt,
|
||||
includeAgentStatus: true,
|
||||
agentStatusOverrides: {
|
||||
state: 'blocked',
|
||||
prompt: 'Allow Bash(rm -rf)?',
|
||||
restoredUnconfirmed: true
|
||||
}
|
||||
}),
|
||||
T0 + 1_000
|
||||
)
|
||||
|
||||
expect(store.getState().agentStatusByPaneKey[MIRROR_PANE_KEY]?.state).toBe('working')
|
||||
})
|
||||
|
||||
it('adopts host monitoring metadata without replacing client-owned byte status', () => {
|
||||
const store = seedPairedClientStore()
|
||||
applyHostSnapshot(
|
||||
|
||||
@@ -68,7 +68,11 @@ export function isMirroredAgentPaneKeyForTabs(
|
||||
* interactive question cards reach the host over its HTTP agent hook, never
|
||||
* through PTY bytes, so they must pierce the client-authority fence. */
|
||||
export function hostAgentStatusPiercesClientAuthority(entry: AgentStatusEntry): boolean {
|
||||
return entry.state === 'blocked' || entry.interactivePrompt != null
|
||||
// Why: a row restored across a host restart is unverifiable, so it never outranks live bytes.
|
||||
return (
|
||||
entry.restoredUnconfirmed !== true &&
|
||||
(entry.state === 'blocked' || entry.interactivePrompt != null)
|
||||
)
|
||||
}
|
||||
|
||||
/** True while this renderer's own byte-derived status owns the pane: it claimed
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY,
|
||||
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
|
||||
AGENT_STATUS_RESTORED_UNCONFIRMED_RUNTIME_CAPABILITY,
|
||||
REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY,
|
||||
AGENT_SESSION_TURN_ITEM_CAPABILITY,
|
||||
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
|
||||
@@ -28,5 +29,7 @@ export const WEB_RUNTIME_CLIENT_CAPABILITIES = [
|
||||
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY,
|
||||
WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY,
|
||||
WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY,
|
||||
TERMINAL_QUICK_COMMAND_LONG_PROMPTS_RUNTIME_CAPABILITY
|
||||
TERMINAL_QUICK_COMMAND_LONG_PROMPTS_RUNTIME_CAPABILITY,
|
||||
// Same renderer as the desktop: a restored, unconfirmed agent row shows as identity only.
|
||||
AGENT_STATUS_RESTORED_UNCONFIRMED_RUNTIME_CAPABILITY
|
||||
] as const
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY,
|
||||
AGENT_SESSION_TURN_ITEM_CAPABILITY,
|
||||
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
|
||||
AGENT_STATUS_RESTORED_UNCONFIRMED_RUNTIME_CAPABILITY,
|
||||
REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY,
|
||||
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
|
||||
SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY,
|
||||
@@ -138,7 +139,8 @@ describe('WebRuntimeClient', () => {
|
||||
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY,
|
||||
WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY,
|
||||
WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY,
|
||||
TERMINAL_QUICK_COMMAND_LONG_PROMPTS_RUNTIME_CAPABILITY
|
||||
TERMINAL_QUICK_COMMAND_LONG_PROMPTS_RUNTIME_CAPABILITY,
|
||||
AGENT_STATUS_RESTORED_UNCONFIRMED_RUNTIME_CAPABILITY
|
||||
]
|
||||
})
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { TERMINAL_IMAGE_VIEWER_CAPABILITY } from './terminal-image-viewer-capabi
|
||||
import {
|
||||
AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY,
|
||||
AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY,
|
||||
AGENT_STATUS_RESTORED_UNCONFIRMED_RUNTIME_CAPABILITY,
|
||||
BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY,
|
||||
BROWSER_CLIENT_PAGE_METADATA_RUNTIME_CAPABILITY,
|
||||
CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
|
||||
@@ -41,5 +42,7 @@ export const ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [
|
||||
// Main restores every tab-delta frame before the window or any other reader sees it.
|
||||
SESSION_TABS_TAB_DELTA_RUNTIME_CAPABILITY,
|
||||
// Only the renderer shows Deleting for a `removing` row; CLI and mobile get those rows omitted.
|
||||
WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY
|
||||
WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY,
|
||||
// The renderer shows a restored, unconfirmed agent row as identity only.
|
||||
AGENT_STATUS_RESTORED_UNCONFIRMED_RUNTIME_CAPABILITY
|
||||
] as const
|
||||
|
||||
@@ -97,6 +97,10 @@ export const SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY =
|
||||
// Why: a client advertising this rebuilds each `updated` frame's tab list from the previous frame
|
||||
// on the same stream, so one tab's status change no longer re-sends every tab in the worktree.
|
||||
export const SESSION_TABS_TAB_DELTA_RUNTIME_CAPABILITY = 'session-tabs.tab-delta.v1' as const
|
||||
// Why: a client advertising this reads `restoredUnconfirmed` agent rows as unverifiable; older
|
||||
// clients would read their last known state as live, so they keep the legacy `done` projection.
|
||||
export const AGENT_STATUS_RESTORED_UNCONFIRMED_RUNTIME_CAPABILITY =
|
||||
'agent-status.restored-unconfirmed.v1' as const
|
||||
export const AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY =
|
||||
'agent-session.session-boundary.v1' as const
|
||||
export const AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY =
|
||||
|
||||
Reference in New Issue
Block a user