mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 08:03:37 +00:00
feat(native-chat): canonical agent-session journal with crash-safe receipts and epoch cursors (phase 1c)
Adds the host-side append-only journal that a native chat session will read and write: one durable log per agent session, one reducer shared by live events and replay, and epoch-qualified cursors so a reconnecting client either resumes exactly or takes a clean snapshot reload. Nothing is wired up yet. No RPC, no adapters, no UI: the journal is dormant until a later phase feeds it, so behavior for existing sessions is unchanged. - Append-only JSONL log plus an atomic snapshot that carries its own retained tail, so compaction is a single durable step and a crash mid-compaction leaves the log a recoverable superset. - Sequences are assigned inside the same serialized append that makes the row durable, giving the (epoch, sequence) space exactly one writer with no gaps and no reuse. Every write carries the runtime fence; a stale fence is refused. - A write-ahead submission row is durable before dispatch and doubles as the optimistic user bubble, so an accepted provider echo upserts into the bubble the user already sees instead of adding a second copy of their own message. - Dispatch is a state machine: pending, accepted, rejected, or unknown. Unknown is a displayed state meaning "delivery unconfirmed"; Orca never re-sends on the user's behalf and never silently drops the message. - Items upsert by stable provider identity with revisions and tombstones. Identity keying never uses text equality, so asking the same question twice stays two messages. - Payload bounds keep a head plus the byte length and digest inline and push the remainder to a content-addressed blob store; crossing a bound is always marked, never a silent truncation. - A row written by a newer schema puts the host into read-only mode rather than being skipped, so a newer client's rows are never dropped or compacted away. - Legacy import hydrates a journal by running the existing transcript decoders unchanged and reading identity off the same raw lines they consume.
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
// Item-identity → stable journal key. Pure and shared: the host keys upserts
|
||||
// with it and clients reconcile optimistic sends against the same string.
|
||||
//
|
||||
// Components are percent-encoded before joining so a value containing the
|
||||
// delimiter cannot collide with a different identity.
|
||||
|
||||
import type { AgentJournalItemIdentity } from './agent-session-journal-types'
|
||||
|
||||
const KEY_DELIMITER = ':'
|
||||
|
||||
function encodePart(value: string | number): string {
|
||||
return encodeURIComponent(String(value))
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable string key for an item identity.
|
||||
*
|
||||
* Codex renumbers `item-N` ids on every resume, so its key is the thread, the
|
||||
* turn, and the item's ordinal WITHIN that turn — a position that survives
|
||||
* renumbering because a completed turn's item list does not change. `thread/fork`
|
||||
* copies turns keeping their original turn ids, so the thread id must stay in the
|
||||
* key. Claude copies item uuids on `--fork-session`, so its key is the session id
|
||||
* plus the uuid. Text never participates.
|
||||
*/
|
||||
export function agentJournalItemKey(identity: AgentJournalItemIdentity): string {
|
||||
if (identity.provider === 'codex') {
|
||||
return [
|
||||
'codex',
|
||||
encodePart(identity.threadId),
|
||||
encodePart(identity.turnId),
|
||||
encodePart(identity.ordinal)
|
||||
].join(KEY_DELIMITER)
|
||||
}
|
||||
if (identity.provider === 'claude') {
|
||||
return ['claude', encodePart(identity.sessionId), encodePart(identity.uuid)].join(KEY_DELIMITER)
|
||||
}
|
||||
if (identity.provider === 'orca') {
|
||||
return ['orca', encodePart(identity.clientMessageId)].join(KEY_DELIMITER)
|
||||
}
|
||||
return [
|
||||
'legacy',
|
||||
encodePart(identity.agent),
|
||||
encodePart(identity.sessionId),
|
||||
encodePart(identity.recordId)
|
||||
].join(KEY_DELIMITER)
|
||||
}
|
||||
|
||||
/** Key for the pre-dispatch submission placeholder, before any provider echo. */
|
||||
export function agentJournalSubmissionKey(clientMessageId: string): string {
|
||||
return agentJournalItemKey({ provider: 'orca', clientMessageId })
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
// ─── Canonical agent-session journal: cross-process wire shapes ─────────────
|
||||
// The host-owned timeline for a structured agent session. Everything here must
|
||||
// be plain JSON: rows are persisted verbatim and later republished to clients,
|
||||
// so no class instances, Maps, or Dates.
|
||||
//
|
||||
// Rows are append-only. `schemaVersion` is upcast at read time and never
|
||||
// rewritten in place, so a host that cannot read a row refuses to write the
|
||||
// journal rather than skipping or compacting past it.
|
||||
|
||||
import type { AgentType } from './agent-status-types'
|
||||
import type { NativeChatBlock, NativeChatRole } from './native-chat-types'
|
||||
|
||||
export { type AgentType }
|
||||
|
||||
/** Bump only alongside a read-time upcaster in `journal-row-schema.ts`. */
|
||||
export const AGENT_SESSION_JOURNAL_SCHEMA_VERSION = 1
|
||||
|
||||
/** Epoch-qualified position in one journal. `sequence` 0 means "before the first row". */
|
||||
export type AgentJournalCursor = {
|
||||
epoch: string
|
||||
sequence: number
|
||||
}
|
||||
|
||||
/** The durable provider session a journal is bound to.
|
||||
* Codex is one thread id; Claude needs the leaf because concurrent resumes of
|
||||
* one session id branch the same transcript. */
|
||||
export type AgentSessionProviderHandle =
|
||||
| { kind: 'codex'; threadId: string }
|
||||
| { kind: 'claude'; sessionId: string; leafUuid: string | null }
|
||||
| { kind: 'opaque'; agent: AgentType; value: string }
|
||||
|
||||
/** The narrow slice of the durable session record the journal needs. The full
|
||||
* record (owner, lease, account home) belongs to the session store. */
|
||||
export type AgentSessionJournalIdentity = {
|
||||
/** Orca agent-session id — the journal's primary key. */
|
||||
sessionId: string
|
||||
/** Execution-host workspace key. Identical for a worktree, a folder
|
||||
* workspace, a WSL distro, and an SSH host; never a path. */
|
||||
workspaceId: string
|
||||
/** Execution host that owns the process, so a client restart adjudicates nothing. */
|
||||
hostId: string
|
||||
agent: AgentType
|
||||
providerHandle: AgentSessionProviderHandle
|
||||
}
|
||||
|
||||
// ─── Item identity ──────────────────────────────────────────────────────────
|
||||
// Reconciliation keys, settled by the provider spikes. Codex renumbers items
|
||||
// positionally on resume, so a persisted item id is never an identity. Claude
|
||||
// copies the original uuids on fork, so the uuid is.
|
||||
|
||||
export type AgentJournalItemIdentity =
|
||||
| { provider: 'codex'; threadId: string; turnId: string; ordinal: number }
|
||||
| { provider: 'claude'; sessionId: string; uuid: string }
|
||||
/** A submission Orca minted before any provider echo existed. */
|
||||
| { provider: 'orca'; clientMessageId: string }
|
||||
/** Bridge-era transcript record with no provider-stable identity. */
|
||||
| { provider: 'legacy'; agent: AgentType; sessionId: string; recordId: string }
|
||||
|
||||
// ─── Bounded payloads ───────────────────────────────────────────────────────
|
||||
|
||||
/** A tool output or diff body clipped to a head plus a content-addressed
|
||||
* remainder. Crossing a bound sets `truncated`; it never silently drops. */
|
||||
export type AgentJournalBoundedPayload = {
|
||||
head: string
|
||||
/** Byte length of the ORIGINAL payload, not of `head`. */
|
||||
byteLength: number
|
||||
/** sha256 of the original payload, and the blob store key when `truncated`. */
|
||||
digest: string
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
// ─── Render-model items ─────────────────────────────────────────────────────
|
||||
|
||||
export type AgentJournalMessageItem = {
|
||||
kind: 'message'
|
||||
role: NativeChatRole
|
||||
blocks: NativeChatBlock[]
|
||||
}
|
||||
|
||||
export type AgentJournalToolCallState = 'running' | 'completed' | 'failed'
|
||||
|
||||
export type AgentJournalToolCallItem = {
|
||||
kind: 'tool-call'
|
||||
name: string
|
||||
input: unknown
|
||||
state: AgentJournalToolCallState
|
||||
output?: AgentJournalBoundedPayload
|
||||
}
|
||||
|
||||
export type AgentJournalDiffItem = {
|
||||
kind: 'diff'
|
||||
path: string
|
||||
patch: AgentJournalBoundedPayload
|
||||
}
|
||||
|
||||
export const AGENT_JOURNAL_RESOLUTION_STATES = ['pending', 'resolved', 'cancelled'] as const
|
||||
export type AgentJournalResolutionState = (typeof AGENT_JOURNAL_RESOLUTION_STATES)[number]
|
||||
|
||||
/** Approvals and questions are durable items with explicit resolution state, so
|
||||
* a second client answering one prompt loses the compare-and-set instead of
|
||||
* invoking the provider callback twice. */
|
||||
export type AgentJournalResolution = {
|
||||
state: AgentJournalResolutionState
|
||||
/** Option id the winner picked; null while pending or cancelled. */
|
||||
selectedOptionId: string | null
|
||||
/** Opaque client identity of the resolver, for "answered on <device>". */
|
||||
resolvedBy: string | null
|
||||
resolvedAt: number | null
|
||||
}
|
||||
|
||||
export type AgentJournalPromptOption = {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export type AgentJournalApprovalItem = {
|
||||
kind: 'approval'
|
||||
title: string
|
||||
detail: string | null
|
||||
options: AgentJournalPromptOption[]
|
||||
resolution: AgentJournalResolution
|
||||
}
|
||||
|
||||
export type AgentJournalQuestionItem = {
|
||||
kind: 'question'
|
||||
question: string
|
||||
options: AgentJournalPromptOption[]
|
||||
resolution: AgentJournalResolution
|
||||
}
|
||||
|
||||
export type AgentJournalStatusItem = {
|
||||
kind: 'status'
|
||||
text: string
|
||||
}
|
||||
|
||||
export type AgentJournalItemBody =
|
||||
| AgentJournalMessageItem
|
||||
| AgentJournalToolCallItem
|
||||
| AgentJournalDiffItem
|
||||
| AgentJournalApprovalItem
|
||||
| AgentJournalQuestionItem
|
||||
| AgentJournalStatusItem
|
||||
|
||||
/** One reduced timeline entry. `sequence` orders the list; `observedAt` is the
|
||||
* provider's own clock and may sort earlier than a later sequence when the row
|
||||
* was recovered after a crash. */
|
||||
export type AgentJournalRenderItem = {
|
||||
itemId: string
|
||||
revision: number
|
||||
body: AgentJournalItemBody
|
||||
sequence: number
|
||||
observedAt: number
|
||||
/** Set when the row was appended by crash reconciliation rather than live. */
|
||||
recovered?: true
|
||||
}
|
||||
|
||||
// ─── Submissions ────────────────────────────────────────────────────────────
|
||||
|
||||
export const AGENT_JOURNAL_DISPATCH_STATES = ['pending', 'accepted', 'rejected', 'unknown'] as const
|
||||
export type AgentJournalDispatchState = (typeof AGENT_JOURNAL_DISPATCH_STATES)[number]
|
||||
|
||||
/** The write-ahead submission row, projected. `unknown` is a displayed state:
|
||||
* the turn reads as delivery unconfirmed, never as sent and never as failed. */
|
||||
export type AgentJournalSubmission = {
|
||||
clientMessageId: string
|
||||
fence: number
|
||||
payloadFingerprint: string
|
||||
dispatchState: AgentJournalDispatchState
|
||||
/** Provider item identity adopted on accept; null otherwise. */
|
||||
providerItemId: string | null
|
||||
/** Terminal reason on `rejected`. */
|
||||
reason: string | null
|
||||
submittedAt: number
|
||||
resolvedAt: number | null
|
||||
}
|
||||
|
||||
/** Durable answer to "did my send land?", keyed by client message id. Only an
|
||||
* `accepted` dispatch mints one, and it outlives the journal tail. */
|
||||
export type AgentJournalAcceptanceReceipt = {
|
||||
clientMessageId: string
|
||||
providerItemId: string
|
||||
cursor: AgentJournalCursor
|
||||
acceptedAt: number
|
||||
}
|
||||
|
||||
// ─── Snapshots and cursor resume ────────────────────────────────────────────
|
||||
|
||||
export type AgentJournalSnapshot = {
|
||||
sessionId: string
|
||||
cursor: AgentJournalCursor
|
||||
items: AgentJournalRenderItem[]
|
||||
submissions: AgentJournalSubmission[]
|
||||
}
|
||||
|
||||
/** Why a cursor could not be resumed. Every value forces a clean snapshot
|
||||
* reload on the client. */
|
||||
export const AGENT_JOURNAL_RESET_REASONS = [
|
||||
'epoch_changed',
|
||||
'cursor_ahead',
|
||||
'cursor_compacted',
|
||||
'journal_gap',
|
||||
'schema_unreadable'
|
||||
] as const
|
||||
export type AgentJournalResetReason = (typeof AGENT_JOURNAL_RESET_REASONS)[number]
|
||||
Reference in New Issue
Block a user