Skip to content

Latest commit

 

History

History
232 lines (145 loc) · 37.2 KB

Session Persistence.md

File metadata and controls

232 lines (145 loc) · 37.2 KB
type subsystem
last-updated 2026-10-25
updated-by fe7f6bda
sources
src/services/cache/cache-key.ts
src/shared/cache-save-result.ts
packages/runtime/src/session/storage.ts
packages/runtime/src/session/search.ts
packages/runtime/src/session/prune.ts
packages/runtime/src/session/logical-key.ts
packages/runtime/src/shared/invocation-identity.ts
src/services/artifact/upload.ts
packages/runtime/src/session/archive.ts
packages/runtime/src/session/writeback.ts
packages/runtime/src/session/types.ts
packages/runtime/src/session/discovery.ts
packages/runtime/src/session/backend.ts
packages/runtime/src/session/storage-mappers.ts
packages/runtime/src/session/storage-message-mappers.ts
packages/runtime/src/object-store/content-sync.ts
packages/runtime/src/object-store/s3-adapter.ts
packages/runtime/src/object-store/key-builder.ts
packages/runtime/src/object-store/validation.ts
packages/runtime/src/object-store/types.ts
packages/runtime/src/object-store/settings.ts
packages/runtime/src/object-store/encryption.ts
src/services/cache/restore.ts
src/services/cache/save.ts
RFCs/RFC-004-Session-Management.md
RFCs/RFC-002-Cache-Infrastructure.md
RFCs/RFC-019-S3-Storage-Backend.md
ARCHITECTURE.md
src/harness/post.ts
src/services/cache/types.ts
src/harness/phases/cleanup.ts
summary How agent memory survives across CI runs via cache, SDK sessions, S3 object store, and pruning

Session Persistence

The defining feature of Fro Bot is persistent memory. Unlike typical CI-based AI agents that start fresh every run, Fro Bot preserves its session history across workflow invocations — subject to the trigger constraint documented below. This means the agent can reference prior investigations, avoid repeating work, and build institutional knowledge of a codebase over time.

The Persistence Stack

Session persistence involves three cooperating subsystems:

  1. Cache layer (src/services/cache/) — Saves and restores the OpenCode storage directory to/from GitHub Actions cache, with optional S3 write-through backup.
  2. Session layer (packages/runtime/src/session/) — Reads, searches, and manages individual sessions within that storage directory via the OpenCode SDK. Part of the @fro-bot/runtime package (see [[Architecture Overview]]).
  3. Writeback (packages/runtime/src/session/writeback.ts) — Injects synthetic summary messages into session history after each run, making past work discoverable by future runs.

Cache Strategy

The storage directory (~/.local/share/opencode/) is cached using a branch-scoped key:

opencode-storage-{repo}-{branch}-{os}

Branch scoping prevents one branch's session history from leaking into another. The OS component handles the rare case of cross-platform runners.

Restore and save use that key differently. Restore matches on two prefixes — branch-scoped first, then repo-scoped — while the key a run writes appends its run ID, run attempt, and an invocation identity (the save-key format is in the Invocation Identity table).

The run-attempt component exists because Actions cache entries are immutable: a re-run keeps the same GITHUB_RUN_ID, so without the attempt suffix the second attempt's save collided with the first attempt's entry. The invocation identity exists for the same reason one level up: run ID and attempt name the workflow run, not the job, so two jobs of one run (for example a needs:-chained "Remediate" then "Observe") used to compute the identical key. The second job restored the first job's just-saved entry, then failed to save (Unable to reserve cache … another job may be creating this cache), and its session state was lost. The identity is derived deterministically, so the post-action retry saves under exactly the key the main step tried; see Invocation Identity for its derivation and every key it qualifies.

Each attempt of each job now writes its own entry. Both restore prefixes stop before the run ID, so they continue to match every save key regardless of run, attempt, or job, including older entries written before the identity suffix existed; the cost is that re-runs and multi-job runs consume additional entries against the repository's cache budget, which LRU eviction absorbs. Losing a job's session state was judged the worse outcome.

On restore, the cache module performs several safety checks:

  • Corruption detection — Verifies the storage path is a readable directory. If not, cleans and continues with empty state.
  • Version check — Reads a .version file and compares against the expected STORAGE_VERSION constant. On mismatch, starts fresh to avoid format incompatibilities.
  • Credential cleanup — Deletes any auth.json that might have been accidentally included in a previous cache save. Credentials are ephemeral and should never persist.

The Actions cache is saved up to twice: once during the cleanup phase of the main step, and again in the post-action hook (post.ts) when the first save did not persist. The post-action hook exists because GitHub Actions may kill the main step's finally block, and losing cache would mean losing all session history. The object store keeps one shared sessions/opencode.db per repo, written with an unconditional upload, so only a run that holds the coordination lease may write it. The main step publishes when it acquired the lock (cleanup holds a lock ETag or a lease renewal). A dedup-skipped or routing-skipped run returns before the lock is taken and writes the Actions cache alone; its job summary reads Object Store Publish: withheld (no lease acquired). The post-action hook runs after the lease is released, so its retry passes objectStorePublish: 'withhold' to saveCache and writes the Actions cache alone.

Trigger-specific cache write availability

GitHub scopes the cache JWT by trigger class, not by who fired the individual run: because issue_comment and issues are initiable by an actor without repository write access, every run on those triggers gets a read-only ACTIONS_RUNTIME_TOKEN. On those runs the Actions cache can still restore state, but its write token is not equally capable. A run started by a maintainer is affected exactly as much as one started by an outside contributor — this repository's own workflow admits only OWNER, MEMBER, and COLLABORATOR on issue_comment, and its cache writes were still denied. @actions/cache authenticates with that runner-injected token rather than GITHUB_TOKEN, so adding or changing a workflow permissions: block cannot make the cache writable; the permissions shown for GITHUB_TOKEN are irrelevant to this cache operation. workflow_dispatch and schedule runs are not affected.

This mapping from event to cache scope is attributed to a platform policy change and is not documented by GitHub; it is the current best explanation for the observed behavior on GitHub-hosted runners rather than a published contract. The triggers named here are the ones whose behavior was observed directly — pull_request and pull_request_review_comment were not, and fork pull requests carry their own separate cache-scope restrictions. See the incident write-up for the evidence and the month it went unnoticed.

The result is that a run can restore prior state successfully while its cache write is rejected. Mention-driven runs therefore read session state but may be unable to write their new state. This is a GitHub platform constraint, not a Fro Bot configuration bug. s3-backup defaults to false; enabling it with a configured bucket makes the object store the durable path for session state, and restore consults it before the Actions cache. Object-store sync is best-effort and non-fatal, so continuity depends on the upload succeeding. If the upload fails, the run still falls through to the Actions cache write, which may also be denied.

@actions/cache hides both write denials and reservation collisions behind a -1 sentinel instead of throwing, so the save path cannot distinguish those causes at that boundary. The cache write is therefore reported as not persisted rather than as a confident diagnosis. The post-action hook remains the Actions-cache retry path when the main-step save did not persist; it does not retry the object-store upload. What "did not persist" means precisely is the subject of the next section.

What a Save Reports

saveCache returns a structured CacheSaveResult (src/shared/cache-save-result.ts) rather than a boolean. The shape has two independent persistence axes — cachePersisted and storePersisted — plus a named outcome describing the terminal condition that produced them. Durability overall is the disjunction of the two axes, never either one alone.

The reason for the shape is that a boolean collapsed three genuinely different situations into the same value: a deliberate opt-out (SKIP_CACHE), a denied write, and a successful write. Naming them separately makes the run's actual persistence story legible in the job summary and in the cache-save-result action output. The outcomes are skipped-by-configuration, skipped-empty, checkpoint-declined, ownership-declined, cache-rejected, cache-error, and persisted.

ownership-declined means the save was never attempted at all. Cleanup's persistence safety gate (runCleanup, src/harness/phases/cleanup.ts) refused it because it could not confirm no other writer was still touching this session's state: background subagent work this run owns was still unresolved, the OpenCode server's shutdown never confirmed the writer had quiesced, or the coordination lease could not be renewed. The three reasons are checked in a fixed order and only the first match is reported, on the reasoning that a reader needs one clear cause rather than a list of everything that happened to be true. Unlike the other outcomes here, it is not a retry candidate — it maps to its own declined-for-safety state value (see [[Execution Lifecycle]]) precisely so the post-action hook honors the refusal instead of second-guessing it with less information than cleanup had. [[Background Subagents and Ownership]] covers the gate and why a save on top of a live writer is worse than no save at all.

cache-rejected deserves a note about what it does not claim. @actions/cache distinguishes a policy denial, a reservation collision, a finalize error, a 5xx, and an upload failure internally, but none of those survive the saveCache() call boundary — every one returns the same -1. cache-rejected therefore covers all of them because the boundary cannot tell them apart, not because one was determined over the others. The source comments are explicit that this is an inference and that the cause must not be guessed from the trigger type or runner configuration: a self-hosted runner can hold a writable token on a comment trigger, and a transient service failure can happen regardless of token permissions.

An "already exists" error thrown by the cache adapter is not treated as success. It used to be folded into persisted on the theory that a concurrent job had committed the key, but with job-agnostic keys that "concurrent job" was a different job and this invocation's state was silently lost. It now maps to cache-rejected — the same outcome as the -1 sentinel for the same reservation collision — so the state value is not-persisted (or store-only if the object store landed) and the post hook retries.

Before saving, saveCache in src/services/cache/save.ts checks whether there is anything worth caching — but this check is subtler than "is the storage directory non-empty." Recent OpenCode versions persist sessions in an opencode.db SQLite file in the parent of the storage directory, not inside it, so a naive empty-directory check would skip the save on every real run. The save path checkpoints SQLite before building the transport paths, and today's cache and object-store save sets include opencode.db but not opencode.db-wal or opencode.db-shm. A non-empty opencode.db is therefore the DB-side signal; WAL-only data is expected to have been merged before the save check.

Object Store (S3 Backup)

GitHub Actions cache has a 10 GB limit per repository and entries expire after 7 days of inactivity. For repositories where losing agent memory would be costly, the optional S3-compatible object store backend (RFC-019) provides durable persistence that survives cache eviction — and, per the trigger constraint above, is the only path to continuity at all on GitHub-hosted runners for issue_comment and issues runs.

The implementation lives in packages/runtime/src/object-store/ and consists of five modules:

  • s3-adapter.ts — Creates an ObjectStoreAdapter wrapping @aws-sdk/client-s3. Handles upload (PutObject), download (GetObject with streaming pipeline), and list (ListObjectsV2 with pagination). A companion listWithMetadata operation returns each key alongside its S3 LastModified timestamp, which lets callers scan an object prefix by recency rather than reading every record — the gateway's operator run-index reads runs this way to surface only recent activity (see [[Operator Web Control Surface]]). All S3 error messages are sanitized to strip credentials before logging. The client retries up to 3 times and caps list pagination at 100 iterations, the same cap the metadata variant applies.

  • content-sync.ts — Orchestrates bidirectional sync of three content types. syncSessionsToStore uploads only the main SQLite database file (opencode.db) to S3; the -wal and -shm sidecars are deliberately excluded from upload. syncSessionsFromStore still downloads whatever the store lists, with path traversal validation on every key, so objects written before that change continue to restore. syncArtifactsToStore uploads the OpenCode log directory tree. syncMetadataToStore writes a JSON metadata blob (token usage, timing, session IDs, costs) to S3 via a secure temp file.

  • key-builder.ts — Constructs S3 object keys from config prefix, agent identity, repository, and content type (sessions, artifacts, metadata). Every component is sanitized and validated.

  • validation.ts — Endpoint validation, prefix validation, key component sanitization, and download path traversal checks. validateEndpoint requires HTTPS unless s3-allow-insecure-endpoint is set. It rejects localhost, *.localhost and IP-literal hosts (IPv4, and IPv6 with the brackets removed first) in the loopback, private, unique-local, link-local, unspecified (0.0.0.0/8, ::), CGNAT (100.64.0.0/10), and documentation (RFC 5737 and 2001:db8::/32) ranges, including IPv4-mapped and translated IPv6 forms; one trailing dot on a hostname is ignored. s3-allow-insecure-endpoint lifts these address checks. These metadata targets are always rejected, even with the flag set: 169.254.169.254, 169.254.170.2, 100.100.100.200, fd00:ec2::254, their IPv4-mapped and translated forms, and metadata.google.internal. Address classification lives in packages/runtime/src/net/ip-classification.ts, shared with the gateway's push-endpoint validator. Known limits: hostnames are not DNS-resolved, and NAT64, IPv4-compatible IPv6, the benchmarking range 198.18.0.0/15, .internal and .local names, and the bare single-label name metadata are not classified.

  • types.ts — Defines the ObjectStoreAdapter interface and typed error factories (ValidationError, PathTraversalError, ObjectStoreOperationError). The interface keeps the core upload/download/list operations required and exposes the conditional (ETag-guarded) and recency-aware operations — conditionalPut, conditionalDelete, getObject, and listWithMetadata — as optional, so backends that do not need them are not forced to implement them. conditionalPut also accepts an optional tagging string: the coordination layer (see [[Execution Lifecycle]]) tags its run-state objects so a bucket lifecycle policy can expire them on its own schedule. Tagging is applied only on real AWS S3 (config.endpoint == null); custom S3-compatible endpoints skip it, since their tagging support varies. Session database objects themselves are not tagged — the tag path is specific to the short-lived coordination records, not the durable session backup.

The coordination read and delete operations also accept an optional abort signal. The S3 adapter passes it to the SDK request and, for a streamed getObject response, destroys the body stream when the signal aborts. Cancellation therefore covers both waiting for headers and consuming the body, and returns an error result rather than a successful partial read. This lets the post-action lock-release deadline bound real I/O, not merely stop awaiting a request that continues in the background ([[Execution Lifecycle]]).

How It Integrates

The object store integrates with restore, save, and cleanup:

  1. On restore — When the object store is configured, restoreCache in src/services/cache/restore.ts consults it before the Actions cache, not merely as a miss-time fallback. This ordering exists because some runs — mention-triggered runs in particular — cannot write the Actions cache, so their own state only ever lands in the object store; their successful S3 upload was invisible to a restore that took any cache hit first, including a stale prefix-fallback hit from an unrelated earlier run, leaving them to start cold every time. Consulting durable storage first lets the most recent real session win. A store hit still requires the main session database — sidecar WAL/SHM files alone remain a miss, as with the cache path — and the downloaded storage is validated the same way a cache restore is. A store failure or a disabled store falls through to the cache path unchanged, so the behavior is inert until durable storage is configured. A successful S3 restore reports source: 'storage' in the CacheResult (vs. source: 'cache' for an Actions cache hit).

  2. On save — When publication is permitted by the caller's lease, saveCache in src/services/cache/save.ts calls syncSessionsToStore before the Actions cache write, not after. That ordering is what lets the object store carry continuity on triggers whose cache write is denied. A caller passing objectStorePublish: 'withhold' skips only that shared-session upload and still attempts the Actions cache. The sync is best-effort and non-fatal, so a failed upload leaves the run falling through to a cache write that may itself be denied — S3 holds a recent copy when the upload succeeded, not unconditionally.

  3. On cleanup — The cleanup phase in src/harness/phases/cleanup.ts uploads run artifacts and metadata to S3 via syncArtifactsToStore and syncMetadataToStore. This happens after server shutdown and before the cache save; saveCache performs the SQLite checkpoint. These invocation-scoped objects are distinct from the shared session database whose publication requires the lease.

S3 operations are always best-effort: failures are logged as warnings but never abort the run. The action supports AWS S3, Cloudflare R2, Backblaze B2, and MinIO, with SSE encryption auto-selected per endpoint type (aws:kms for AWS, AES256 for custom endpoints).

The common settings parser (packages/runtime/src/object-store/settings.ts) gives the Action, gateway, and backfill the same prefix, endpoint, expected-owner, and encryption validation. A supplied expected owner is a 12-digit AWS account ID, and a KMS key requires an effective aws:kms mode; custom endpoints therefore need that mode explicitly when using KMS. Surface-specific credential reads and region requirements stay outside the parser. See [[Setup and Configuration]].

SDK Session Operations

All session operations go through the @opencode-ai/sdk client — the session module never does direct file I/O against the storage directory. This is important because OpenCode may change its storage format between versions, and the SDK provides a stable abstraction.

The key operations:

  • listSessionsForProject — Lists sessions for the current workspace, filtered by project directory. Returns SessionInfo objects mapped from SDK types.
  • getSession / getSessionMessages — Retrieves a single session's metadata or its full message history.
  • getSessionTodos — Reads the todo list from a session (uses an untyped SDK endpoint, hence the explicit unknown mapping).
  • deleteSession — Removes a session (used during pruning).

All SDK operations return empty arrays or null on failure — they never throw. This null-safe pattern prevents a single bad session from crashing the entire run.

These runtime-side operations are distinct from the native agent tools described below: the operations above are invoked by harness/runtime code outside of model tool calls, never by the model directly. The native tools are what the model itself calls mid-run.

Native Agent Session Tools

packages/runtime/src/agent/session-tools.ts implements the always-on session_list / session_read / session_search / session_info tool contract as a plain config-dir file tool (tool/session.js), independent of any plugin. Setup copies the compiled asset into the CI OpenCode config dir via writeSessionToolsFile (src/services/setup/session-tools-config.ts); OpenCode's tool registry loads it as a file tool before plugin tools, so a plugin tool of the same id overrides it at registry time (later-wins). The tools resolve an OpenCode client from FRO_BOT_OPENCODE_URL at call time (never at import time) and delegate to the same packages/runtime/src/session/ primitives (listSessions, getSession, getSessionMessages, getSessionTodos, getSessionInfo, searchSessions) described above — they are a thin, string-formatted, model-facing wrapper over the SDK operations, not a separate storage path. Every failure path returns a 'session store unavailable: ...' string rather than throwing, so a session-tool failure never fails the run; if the asset is missing at setup time, setup warns and continues, and the tools are simply absent from that run.

Mapper Architecture

SDK types don't perfectly match the project's local types. The mapper layer in packages/runtime/src/session/ (storage-mappers.ts, storage-message-mappers.ts) translates between them:

  • Session mappers convert SdkSessionExtended (which includes fields the SDK type definitions omit, like permission and time.archived) into the local SessionInfo type.
  • Message mappers convert SDK messages into local Message types, sorting chronologically by time.created. The SDK returns messages unsorted, so this step is essential.
  • Part mappers handle the polymorphic message parts (text, tool calls, tool results) with their associated tool state.
  • Todo mapper operates on unknown because the session todos endpoint isn't typed in the SDK.

The local types in types.ts are authoritative — they define the canonical shapes that all downstream code consumes.

Session Search

The search module (packages/runtime/src/session/search.ts) provides two capabilities consumed by both the prompt builder and the native session_list / session_search agent tools:

  • listSessions — Returns recent non-child sessions sorted by updatedAt. Child sessions (those with a parentID, representing agent-spawned branches) are filtered out of the main listing, and so are sessions marked archived or compacting (session.time.archived or session.time.compacting set). A session that has been retired by overflow recovery (below) therefore disappears from the listing while remaining in storage for history.
  • searchSessions — Full-text search across session message content. Returns excerpts with context so the agent can decide which prior sessions are relevant without reading every message. Callers can pass an excludeSessionIds set to skip specific sessions — used by recovery flows that must not re-surface the session they are replacing.

During the session-prep phase of each run (see [[Execution Lifecycle]]), the system searches for sessions related to the current issue or PR. Matching excerpts are injected into the prompt as "Relevant Prior Work," giving the agent a lightweight summary of past interactions. Mid-run, the agent can additionally reach for the same search directly via the native session_search tool instead of waiting on the pre-injected excerpts.

Archiving and Overflow Recovery

When a session grows past the model's context window, continuing to resume it would fail on every attempt. The archive helper (packages/runtime/src/session/archive.ts) retires such a session by stamping time.archived on it. Because the v1 SDK session client can read time.archived but its typed update body only exposes {title?}, archiving is written through the v2 session.update endpoint — this is the one place the runtime reaches past v1 for a write. Once archived, the session is invisible to listSessions and, crucially, to logical-key resolution (below), so the next run resolves to a fresh thread instead of resuming the overflowed one. The retired session is not deleted; it stays available for audit and for searchSessions history until pruning eventually reclaims it.

Logical Session Keys

Continuity depends on each run resolving to a stable logical session key derived from the triggering context (packages/runtime/src/session/logical-key.ts). For entity-bound events — issue comments, PR reviews, and the like — the key is built from the issue or PR identity, so a follow-up comment resumes the same thread the agent was already working in. Resolution filters candidate sessions for eligibility — dropping any that are archived or compacting — before matching by title, in both the workspace-scoped and global lookups. This means an overflowed session that was archived by recovery can never be re-selected by title, even though its title still matches; the resolver skips past it to an eligible thread or starts fresh.

Time-based triggers are subtler. Earlier, schedule runs keyed their logical session on the cron expression alone. Every scheduled run therefore resumed one ever-growing thread. As that single session's history bloated, the agent would read it, conclude the work was already done, and exit without making any tool calls — reporting success while silently doing nothing. To fix this, the schedule key now appends the workflow run ID to the cron-derived hash. Each scheduled run starts a fresh thread, while same-run reruns (which share a run ID) still resume correctly. The key is also qualified by the job's invocation identity (see Invocation Identity): two jobs of one scheduled run — say, Remediate then Observe — are different tasks, and without the job component the second resolved the first's key and continued its root session. The trade-off is deliberate: scheduled maintenance tasks are expected to be idempotent against the repository state they inspect, not against an accumulating conversation, so cross-run memory still flows through run summary writeback and searchSessions rather than through a shared thread.

Pruning

Without pruning, the storage directory would grow unboundedly. The pruning module (packages/runtime/src/session/prune.ts) uses a dual-condition retention policy:

A session is kept if either:

  • Its age is below the maximum age cutoff (default: 30 days), or
  • Its index is within the maximum session count (default: 50, configurable via the session-retention input).

This "age OR count" approach prevents both unbounded growth (count limit) and premature deletion of recent sessions (age limit). When a parent session is pruned, its child sessions are cascade-deleted to avoid orphans.

Run Summary Writeback

After each run, the finalize phase writes a synthetic user message into the session containing a structured summary of what the agent did — including the event type, repository, cache status, session ID, and any errors. This message becomes searchable by future runs, enabling the agent to find its own prior work via searchSessions.

The writeback uses role: 'user' for the synthetic message so the OpenCode session system treats it as input rather than agent output, which keeps the session in a consistent state for potential continuation.

Implementation Reference

Symbol-level reference for mechanisms that ARCHITECTURE.md summarizes under Cross-Cutting Concerns.

SQLite WAL Checkpoint Repair (Cache Bootstrap Trap)

src/services/cache/checkpoint.ts (checkpointDatabase) merges the OpenCode session database's write-ahead log into the main opencode.db file via PRAGMA wal_checkpoint(TRUNCATE) (node:sqlite, unflagged on Node 24), and runs at two call sites: inside saveCache (src/services/cache/save.ts), before file sizes are inspected or bytes transported, and again on the restore side in runCacheRestore (src/harness/phases/cache-restore.ts), before bootstrapOpenCodeServer ever opens the database. Restore keys are prefixes that return the most recent entry and save keys are unique per run, so a run that declines to save leaves a poisoned entry as the newest one for the next restore to hit again — checkpointing on restore heals a stuck repository in place instead of letting it loop. Success is judged by the WAL's on-disk size after the attempt, never by the pragma's own checkpointed count (verified to under-report on a fully successful truncation). A busy or lock-contended writer surfaces as a busy database and is retried within a bounded attempt count and wall-clock deadline; the deadline is checked only between attempts, so an in-flight pragma is never interrupted. An idle live writer does not surface this way at all: a checkpoint against it can report success and then have the WAL grow again moments later from a write that was already in flight when the child was signalled. Closing that gap is packages/runtime/src/agent/server.ts's job, not the checkpoint's — OpenCodeServerHandle.shutdown() sends the child's kill signal and then polls its listening port (bounded, best-effort; the SDK exposes no pid or exit event to await directly) until connections start being refused or a timeout elapses, and src/harness/phases/cleanup.ts awaits that before saveCache ever runs. A timed-out poll does not fail the run, but is logged, since the checkpoint that follows is not then running against a confirmed-quiet database.

The write-ahead log itself no longer crosses either transport on save: DB_TRANSPORTABLE_BASENAMES (packages/runtime/src/session/version.ts) and buildCachePaths (src/services/cache/paths.ts) both include only the main database file. The object store's per-key upload/overwrite is not atomic across two files, so a healthy save that only refreshes opencode.db could otherwise leave an older opencode.db-wal object paired with a newer database on the next restore; src/services/cache/restore.ts deletes any write-ahead log downloaded from the object store immediately after the sync call returns, before anything opens the database. Restore and save now build the Actions-cache path list from that same shared buildCachePaths, so its version hash is identical by construction; a legacy database+WAL cache entry (built from the old five-path list) no longer matches that hash and is therefore never restored at all, not repaired in place.

src/services/cache/integrity.ts (verifyDatabaseUsable) closes the gap a hot WAL would otherwise mask: a structurally corrupt database with no pending write-ahead data reports nothing-to-checkpoint and would sail through unprobed, later getting re-persisted under a fresh key. It runs only on the restore path (SELECT count(*) FROM sqlite_master, a schema-page read, not a full PRAGMA integrity_check) and only when checkpointDatabase itself reports nothing-to-checkpoint.

src/services/cache/sqlite-errors.ts (isStructuralCorruptionError) is a positive allowlist — matching only SQLite's own wording for "file is not a database" and "database disk image is malformed" — never a catch-all. Deleting a repository's session history (cleanStorage) is the most destructive action either module takes, so any unrecognized failure (permission denied, disk full, I/O error, a missing parent directory, a merely non-writable-but-readable database) defaults to structural: false / usable: true and is left alone rather than wiped. Only a positive corruption classification routes into cleanStorage, the same clean-slate path restoreCache already uses for cache-corruption and storage-version mismatches.

Cache Save Result Contract

The outcome shape and what each outcome means are described under What a Save Reports. This section covers how a result becomes a state value and where that value is written.

toCacheSaveStateValue (src/shared/cache-save-result.ts) maps a CacheSaveResult to one of five CacheSaveStateValues (durable, store-only, skipped, declined-for-safety, not-persisted) via OUTCOME_TO_STATE_VALUE, a satisfies Record<CacheSaveOutcome, ...>-pinned table (the same exhaustiveness pattern as RESPONSE_SURFACE_POLICIES in packages/runtime/src/agent/prompt.ts): a new outcome added to the union without a case here fails check-types rather than silently mapping to nothing. cache-rejected/cache-error map to store-only when storePersisted is true, not-persisted otherwise — a store-only save is durable through the other backend, never inferred as a retry-worthy failure.

src/harness/phases/cleanup.ts writes that value on every path a result exists (not just the successful one) to three places: core.saveState(STATE_KEYS.CACHE_SAVED, ...) for the post hook, setCacheSaveResultOutput (src/harness/config/outputs.ts) for the cache-save-result Action output (declared in action.yaml), and writeCacheSaveResultSummary (src/features/observability/job-summary.ts) for a standalone "Session Persistence" job-summary row — written separately from writeJobSummary because the save happens in cleanup, after runFinalizeWithResult has already written and flushed the main summary table. src/harness/post.ts reads CACHE_SAVED back via parseCacheSaveStateValue (absent or unrecognized values, e.g. from an older action version, fail toward not-persisted — retry, never skip) and skips its retry on durable/store-only/skipped/declined-for-safety, retrying only on not-persisted. The declined-for-safety skip is not an optimization like the other three (the rationale is in [[Background Subagents and Ownership]]); unlike not-persisted, which always retries because the post hook is the last chance to persist state. The post hook cannot set the cache-save-result output for its own retry: runs.post: steps execute after every other step in the job, so no step could exist to read a value set there; it writes its own job-summary row instead, since that may report a red state the main step's summary never saw.

Invocation Identity (job-qualified keys)

getInvocationIdentity (packages/runtime/src/shared/invocation-identity.ts, re-exported by src/shared/env.ts) supplies the job-level component that the run ID and attempt (which name the workflow run, not the job; see Cache Strategy) lack: the sanitized GITHUB_JOB, plus -m<8 hex> — a short hash of the canonicalized matrix context — for a matrix leg. It is deterministic (no random nonce), so a re-run of the same job derives the same identity, and it is derived from env (GITHUB_JOB, INPUT_MATRIX-CONTEXT) rather than handed off through state, so the main step and the post-action hook always agree. The matrix-context input defaults to ${{ toJSON(matrix) }} in action.yaml (matrix is an allowed context for input defaults in the action metadata schema); null/{}/empty add nothing, and outside a runner (GITHUB_JOB unset) the identity is null and the segment is omitted.

Surface Format Where
Cache save key {primary}-{runId}-{runAttempt}[-{identity}] buildSaveCacheKey (src/services/cache/cache-key.ts)
Log artifact name opencode-logs-{runId}-{runAttempt}[-{identity}] buildLogArtifactName (src/services/artifact/upload.ts)
Schedule session key schedule-{cronHash}-{runId}[-{identity}] buildLogicalKey (packages/runtime/src/session/logical-key.ts)
Dispatch session key dispatch-{runId}[-{identity}] same
Issue / PR / discussion session keys issue-{n}, pr-{n}, discussion-{n} — unchanged same

The identity goes after runId-runAttempt in the save key, which keeps the restore prefixes matching (see Cache Strategy). A re-run of the same job gets a new runAttempt (fresh, savable cache key and artifact name) but the same run-scoped session key — the logical key is deliberately run-scoped, not attempt-scoped, so a re-run continues its session. Only run-scoped keys (schedule, dispatch) are job-qualified: they name the run, so the executing job is part of the entity. Entity-bound keys name a GitHub object and intentionally stay shared across jobs and runs. Known limit: matrix legs only differ when the leg's matrix values differ, and two calls of one reusable workflow whose inner job id is identical share GITHUB_JOB.