mirror of
https://github.com/stablyai/orca.git
synced 2026-11-05 19:47:27 +00:00
Startup shows chats as Interrupted or Done without reading their history (#32146)
* feat(agent-session-journal): save each chat's open root turn beside its epoch A restart could only tell whether a chat was cut mid-turn by replaying its whole journal. journal_sessions now keeps the open root turn (open_turn_id, open_turn_since), set by the row that opens a turn and cleared only by a finish the provider reported, in that row's own transaction. A turn a quit, crash or Stop cut short stays open, which is what the next launch reads as Interrupted. Journal schema version 5 adds the two nullable columns with a forward migration; a new epoch starts with no turn open. * fix(native-chat): restore chat tabs at startup without replaying any history Startup opened every chat in the saved tab index and replayed its whole journal into the main process to learn its status. With hundreds of tabs and gigabytes of history, Orca froze in GC on every launch. Startup now lists each restored chat from its record and publishes its row as Interrupted (a turn was open when its host stopped, or the last quit's resume offer lists it) or Done, through the status stream and the agent-status sink, reading only each chat's journal_sessions row and its newest row's time. A chat's history is read when something opens it; that open settles what the gone generation left running and publishes the journal's own row over the restored one. No other startup path replays chats: restart offers list from their markers without opening chats, and orchestration mail opens the chats it must read one at a time. Opened chats are capped at 24 resident folds; opening one past the cap closes the least recently active folds that are only a cache, with the idle sweep's own close test. * Fix lazy chat restoration and cache ownership * Restore saved chat work across runtime restarts * Wait for committed structural progress in migration fixture * Correct restored ownership and bounded journal recovery * Preserve bounded work selection during legacy recovery * Complete deferred restart status publication * Fix restart recovery test fixtures * Use legal legacy recovery fixtures * Correct population delete observation * Bound legacy journal replay in a readonly worker * Repair legacy worker proof oracles * Reduce legacy scratch key serialization cost * Decode population meter namespaces with scratch UTF-16 encoding * Consolidate structured session imports for CI * Remove redundant casts and document checked type exceptions * Correct compiler contracts and retain journal fixture invariants * Fix checked test fixture contracts after compiler validation * Name population fixture identity type in exported signature * Preserve historical quit presentation after rewind * Fix structured session retry and remote worker delivery * Preserve settlement and restored status contracts * Enable legacy chat readiness on standalone hosts * Repair structured session static checks * Verify retained submission identity across rewinds * Show saved chat status without replaying history at startup * Keep chats closed when startup status finishes loading * Keep startup owner recovery independent of history loading * Preserve intentional quit verdicts in saved chat status * Use a checked host fixture in startup restoration tests * Group saved startup roster publication and verify scoped status work * Use checked array access in status cut counters * Complete checked saved status fixture typing * Declare portable roster counter contract * Use current saved chat startup APIs in contracts * Preserve complete chat tab authority across startup * Repair checked startup restoration test consumers * Keep authority fixture module mocks in test entries * Consolidate authority test fixture imports * Check restore fixture contracts and recorded queue recovery --------- Co-authored-by: m4air <m4air@Mac.localdomain> Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
This commit is contained in:
co-authored by
m4air
Jinjing
parent
78ef844224
commit
641e4792b9
@@ -0,0 +1,36 @@
|
||||
import { isAgentTurnOutcome, type AgentTurnOutcome } from './agent-turn-outcome'
|
||||
|
||||
export type AgentSessionLastTurn = {
|
||||
state: 'open' | 'settled'
|
||||
outcome?: AgentTurnOutcome
|
||||
at: number
|
||||
prompt?: string
|
||||
reply?: string
|
||||
}
|
||||
|
||||
export const AGENT_SESSION_TURN_PREVIEW_CHARS = 280
|
||||
|
||||
/** Optional metadata must never make the conversation itself unreadable. */
|
||||
export function isAgentSessionLastTurn(value: unknown): value is AgentSessionLastTurn {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
'state' in value &&
|
||||
(value.state === 'open' || value.state === 'settled') &&
|
||||
'at' in value &&
|
||||
typeof value.at === 'number' &&
|
||||
Number.isSafeInteger(value.at) &&
|
||||
value.at >= 0 &&
|
||||
(!('outcome' in value) || value.outcome === undefined || isAgentTurnOutcome(value.outcome)) &&
|
||||
(!('prompt' in value) || isPreview(value.prompt)) &&
|
||||
(!('reply' in value) || isPreview(value.reply))
|
||||
)
|
||||
}
|
||||
|
||||
function isPreview(value: unknown): boolean {
|
||||
return (
|
||||
value === undefined ||
|
||||
(typeof value === 'string' && value.length <= AGENT_SESSION_TURN_PREVIEW_CHARS)
|
||||
)
|
||||
}
|
||||
@@ -25,10 +25,11 @@ export type PersistedAgentSessionLease = Omit<AgentSessionLease, 'runtimeKind' |
|
||||
/** A record as a row stores it. Decode through `decodePersistedAgentSessionRecord`. */
|
||||
export type PersistedAgentSessionRecord = Omit<
|
||||
AgentSessionRecord,
|
||||
'lease' | 'providerHandleChain' | 'sleptAt'
|
||||
'lease' | 'providerHandleChain' | 'sleptAt' | 'lastTurn'
|
||||
> & {
|
||||
lease: PersistedAgentSessionLease
|
||||
sleptAt?: unknown
|
||||
lastTurn?: unknown
|
||||
providerHandleChain: PersistedAgentSessionProviderHandleLink[]
|
||||
}
|
||||
|
||||
|
||||
@@ -421,3 +421,29 @@ describe('a chat whose saved conversation could not be restored', () => {
|
||||
expect(decodePersistedAgentSessionProviderHandleChain(stored)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
null,
|
||||
'open',
|
||||
{},
|
||||
{ state: 'open', at: -1 },
|
||||
{ state: 'settled', at: 10, outcome: 'newer' },
|
||||
{ state: 'open', at: 10, prompt: 'p'.repeat(281) }
|
||||
])('ignores malformed optional last-turn metadata: %j', (lastTurn) => {
|
||||
const stored = { ...encodeAgentSessionRecord(agentSessionRecordFixture()), lastTurn }
|
||||
expect(isPersistedAgentSessionRecord(stored)).toBe(true)
|
||||
expect(decodePersistedAgentSessionRecord(stored).record.lastTurn).toBeUndefined()
|
||||
})
|
||||
|
||||
it('preserves the saved turn and unknown JSON through a record transition', () => {
|
||||
const stored = {
|
||||
...encodeAgentSessionRecord(agentSessionRecordFixture()),
|
||||
lastTurn: { state: 'settled', outcome: 'success', at: 10 },
|
||||
laterField: 'keep'
|
||||
}
|
||||
const record = decodePersistedAgentSessionRecord(stored).record
|
||||
expect(encodeAgentSessionRecord({ ...record, updatedAt: 20 })).toMatchObject({
|
||||
lastTurn: stored.lastTurn,
|
||||
laterField: 'keep'
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
decodePersistedAgentSessionProviderHandleChain,
|
||||
encodePersistedAgentSessionProviderHandleChain
|
||||
} from './agent-session-provider-handle'
|
||||
import { isAgentSessionLastTurn } from './agent-session-last-turn'
|
||||
import type { AgentSessionRecord } from './agent-session-record'
|
||||
|
||||
/** Report decode changes needing writeback; handle encoding alone is unchanged across builds and needs none. */
|
||||
@@ -22,12 +23,13 @@ export function decodePersistedAgentSessionRecord<L extends PersistedAgentSessio
|
||||
if (!providerHandleChain) {
|
||||
throw new Error('agent_session_provider_handle_invalid')
|
||||
}
|
||||
const { sleptAt, ...rest } = record
|
||||
const { sleptAt, lastTurn, ...rest } = record
|
||||
const validSleep = typeof sleptAt === 'number' && Number.isFinite(sleptAt) && sleptAt > 0
|
||||
return {
|
||||
record: {
|
||||
...rest,
|
||||
...(validSleep ? { sleptAt } : {}),
|
||||
...(isAgentSessionLastTurn(lastTurn) ? { lastTurn } : {}),
|
||||
providerHandleChain,
|
||||
lease: normalizeLegacyHandoffLease(record.lease)
|
||||
},
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from './agent-session-legacy-handoff-lease'
|
||||
/** Identity pins location, provider and account home independently of terminal tabs; the lease separately grants one process writer authority. */
|
||||
|
||||
import type { AgentSessionLastTurn } from './agent-session-last-turn'
|
||||
import type { ExecutionHostId } from './execution-host'
|
||||
import {
|
||||
isAgentSessionConversationCommandRecord,
|
||||
@@ -140,6 +141,8 @@ export type AgentSessionRecord = {
|
||||
conversationNameSource?: 'user'
|
||||
/** When the user's Sleep stopped this chat; the next admitted send clears it. */
|
||||
sleptAt?: number
|
||||
/** Latest turn edge for lists that must not open conversation history. */
|
||||
lastTurn?: AgentSessionLastTurn
|
||||
launchArgs?: AgentSessionLaunchArgs
|
||||
lease: AgentSessionLease
|
||||
createdAt: number
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { AgentSessionLastTurn } from './agent-session-last-turn'
|
||||
import type { AgentSessionRecord } from './agent-session-record'
|
||||
import type { AgentSessionStatusSummary } from './agent-session-wire'
|
||||
import type { AgentTurnOutcome } from './agent-turn-outcome'
|
||||
import {
|
||||
agentSessionRecordFixture,
|
||||
agentSessionLeaseFixture
|
||||
} from './agent-session-record.test-fixture'
|
||||
|
||||
export const SAVED_STATUS_AT = 22_000
|
||||
|
||||
export const savedStatusContracts = (
|
||||
[
|
||||
[{ state: 'settled', outcome: 'success', at: SAVED_STATUS_AT }, 'success', 2, null],
|
||||
[{ state: 'open', at: SAVED_STATUS_AT }, 'cancellation', 5, 'Interrupted by user'],
|
||||
[
|
||||
{ state: 'settled', outcome: 'cancellation', at: SAVED_STATUS_AT },
|
||||
'cancellation',
|
||||
5,
|
||||
'Interrupted by user'
|
||||
],
|
||||
[{ state: 'settled', outcome: 'failure', at: SAVED_STATUS_AT }, 'failure', 2, 'Failed'],
|
||||
[
|
||||
{ state: 'settled', outcome: 'interruption', at: SAVED_STATUS_AT },
|
||||
'interruption',
|
||||
2,
|
||||
'Failed'
|
||||
]
|
||||
] satisfies [AgentSessionLastTurn, AgentTurnOutcome, number, string | null][]
|
||||
).map(([lastTurn, outcome, attentionClass, text]) => ({
|
||||
record: {
|
||||
...agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId: 'session-1' })),
|
||||
sessionId: 'session-1',
|
||||
location: {
|
||||
executionHostId: 'local',
|
||||
wslDistro: null,
|
||||
workspaceId: 'wt-1',
|
||||
workspaceKind: 'git-worktree'
|
||||
},
|
||||
updatedAt: SAVED_STATUS_AT + 60_000,
|
||||
lastTurn: { ...lastTurn, prompt: 'saved prompt', reply: 'saved reply' }
|
||||
} satisfies AgentSessionRecord,
|
||||
summary: {
|
||||
sessionId: 'session-1',
|
||||
workspaceId: 'wt-1',
|
||||
agent: 'claude',
|
||||
status: 'idle',
|
||||
latestPrompt: 'saved prompt',
|
||||
lastAssistantMessage: 'saved reply',
|
||||
runtimePermission: null,
|
||||
turnOutcome: outcome,
|
||||
statusStartedAt: SAVED_STATUS_AT,
|
||||
updatedAt: SAVED_STATUS_AT
|
||||
} satisfies AgentSessionStatusSummary,
|
||||
outcome,
|
||||
attentionClass,
|
||||
text
|
||||
}))
|
||||
@@ -10,7 +10,10 @@ import { STALE_SESSION_ROW_PREFIX } from '../agent-session-stop-row-identity'
|
||||
import { readAgentJournalTurn, readAgentJournalTurnOutcome } from '../agent-session-turn-record'
|
||||
import { agentTurnVerdict, type AgentTurnOutcome } from '../agent-turn-outcome'
|
||||
import { structuredAgentSessionCommandTurnItemIds } from '../structured-agent-session-command-entry'
|
||||
import { isStructuredAgentSessionNonRequestRow } from '../structured-agent-session-latest-request'
|
||||
import {
|
||||
isStructuredAgentSessionNonRequestRow,
|
||||
type StructuredAgentSessionLatestRequest
|
||||
} from '../structured-agent-session-latest-request'
|
||||
|
||||
// Dedupe shutdown rows by stopped child; preserve the legacy prefix so older clients add no duplicate notice.
|
||||
export function orcaShutdownRowClientMessageId(
|
||||
@@ -90,3 +93,16 @@ export function latestNativeChatOrcaStopCut(
|
||||
const cause = findInNativeChatTurn(items, turnItemId, orcaStopCauseOfRow)
|
||||
return cause ? { turnItemId, cause } : null
|
||||
}
|
||||
|
||||
/** Intentional shutdown is cancellation in status; retain raw cut verdicts for Continue. */
|
||||
export function nativeChatStatusTurnOutcome(
|
||||
request: StructuredAgentSessionLatestRequest | null,
|
||||
items: readonly AgentJournalRenderItem[],
|
||||
submissions: readonly Pick<AgentJournalSubmission, 'dispatchState'>[]
|
||||
): AgentTurnOutcome | null {
|
||||
const verdict = request
|
||||
? agentTurnVerdict({ state: request.turnState, outcome: request.outcome })
|
||||
: null
|
||||
const stop = verdict === 'interruption' ? latestNativeChatOrcaStopCut(items, submissions) : null
|
||||
return stop && stop.cause !== 'crash' ? 'cancellation' : verdict
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ export function latestStructuredAgentSessionRequest(
|
||||
id: item.itemId,
|
||||
turnState: null,
|
||||
outcome: 'failure',
|
||||
settledAt: submission.resolvedAt ?? undefined
|
||||
settledAt: submission.resolvedAt ?? submission.submittedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -135,12 +135,12 @@ function rejectedSubmissionsByItem(
|
||||
return rejected
|
||||
}
|
||||
|
||||
/** A turn recovery settled ended when that settle was written: when the user learns it stopped. */
|
||||
/** Recovery dates its own settle; older provider ends without clocks use their turn publication. */
|
||||
function turnEndedAt(
|
||||
item: AgentJournalRenderItem,
|
||||
turn: AgentJournalTurnLifecycle
|
||||
): number | undefined {
|
||||
return item.recoveredAt ?? turn.completedAt
|
||||
return item.recoveredAt ?? turn.completedAt ?? item.observedAt
|
||||
}
|
||||
|
||||
function messageProse(blocks: readonly NativeChatBlock[]): string {
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
type AgentJournalRenderItem,
|
||||
type AgentJournalSubmission
|
||||
} from './agent-session-journal-types'
|
||||
import { agentTurnVerdict, type AgentTurnOutcome } from './agent-turn-outcome'
|
||||
import type { AgentTurnOutcome } from './agent-turn-outcome'
|
||||
import { agentJournalToolCallLifecycle } from './agent-journal-tool-call-lifecycle'
|
||||
import { agentJournalLinkageFields } from './agent-session-journal-producer'
|
||||
import { structuredAgentSessionStatusBlock } from './structured-agent-session-status-block'
|
||||
@@ -41,6 +41,7 @@ import { structuredAgentSessionStatusStartedAt } from './structured-agent-sessio
|
||||
import { owesStructuredAgentSessionWork } from './structured-agent-session-owed-work'
|
||||
import type { StructuredAgentSessionWorkScope } from './structured-agent-session-main-agent-working'
|
||||
import { agentSessionCurrentContextRows } from './agent-session-context-clear'
|
||||
import { nativeChatStatusTurnOutcome } from './native-chat/native-chat-orca-stop-cut'
|
||||
|
||||
// Keep existing live-turn/unanswered-send consumers on one import path.
|
||||
export {
|
||||
@@ -304,9 +305,7 @@ export function projectStructuredAgentSessionStatusState(
|
||||
const latestRequest = latestStructuredAgentSessionRequest(items, submissions)
|
||||
// Only idle sessions report a finished-request verdict.
|
||||
const request = status === 'idle' ? latestRequest : null
|
||||
const turnOutcome = request
|
||||
? agentTurnVerdict({ state: request.turnState, outcome: request.outcome })
|
||||
: null
|
||||
const turnOutcome = nativeChatStatusTurnOutcome(request, items, submissions)
|
||||
const statusStartedAt = structuredAgentSessionStatusStartedAt(
|
||||
status,
|
||||
items,
|
||||
|
||||
@@ -99,9 +99,9 @@ describe('when the session entered its status', () => {
|
||||
expect(summaryOf([ask, unverifiable]).statusStartedAt).toBe(7_000)
|
||||
})
|
||||
|
||||
it('leaves an idle state undated when the journal records no end for it', () => {
|
||||
it('dates an older turn end by its own publication, leaving message-only history undated', () => {
|
||||
const undated = item('turn', 2, { kind: 'turn', turnId: 't1', state: 'unverifiable' })
|
||||
expect(summaryOf([ask, undated])).not.toHaveProperty('statusStartedAt')
|
||||
expect(summaryOf([ask, undated]).statusStartedAt).toBe(undated.observedAt)
|
||||
expect(summaryOf([ask])).not.toHaveProperty('statusStartedAt')
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user