Documentation
¶
Index ¶
- Constants
- Variables
- func MarshalToolData(claudeSessionID string, claudeDetectedAt time.Time, geminiSessionID string, ...) json.RawMessage
- func MergeToolDataExtras(oldToolData, newToolData json.RawMessage) json.RawMessage
- func MigrateFromJSON(jsonPath string, db *StateDB) (int, int, error)
- func SetGlobal(db *StateDB)
- type ClaimRow
- type CostEventRow
- type GroupRow
- type InstanceRow
- type InstanceStatusUpdate
- type MultiRepoWorktreeData
- type RecentSessionRow
- type StateDB
- func (s *StateDB) AliveInstanceCount() (int, error)
- func (s *StateDB) ClaimSessions(ids []string, scope string, staleAfter time.Duration) (map[string]bool, error)
- func (s *StateDB) CleanDeadInstances(timeout time.Duration) error
- func (s *StateDB) ClearAllInstances() error
- func (s *StateDB) Close() error
- func (s *StateDB) DB() *sql.DB
- func (s *StateDB) DeleteCostEventsForSession(sessionID string) error
- func (s *StateDB) DeleteGroup(path string) error
- func (s *StateDB) DeleteGroupSubtree(path string) error
- func (s *StateDB) DeleteInstance(id string) error
- func (s *StateDB) DeleteInstanceRow(id string) error
- func (s *StateDB) DeleteWatcherEventsForSession(sessionID string) error
- func (s *StateDB) ElectPrimary(timeout time.Duration) (bool, error)
- func (s *StateDB) GetMeta(key string) (string, error)
- func (s *StateDB) Heartbeat() error
- func (s *StateDB) InsertCostEventRow(ev *CostEventRow) error
- func (s *StateDB) InsertInstanceRow(inst *InstanceRow) error
- func (s *StateDB) InsertWatcherEventRow(ev *WatcherEventRow) error
- func (s *StateDB) InstanceExists(id string) (bool, error)
- func (s *StateDB) IsEmpty() (bool, error)
- func (s *StateDB) LastModified() (int64, error)
- func (s *StateDB) LoadClaims() (map[string]ClaimRow, error)
- func (s *StateDB) LoadCostEventsForSession(sessionID string) ([]*CostEventRow, error)
- func (s *StateDB) LoadGroup(path string) (*GroupRow, error)
- func (s *StateDB) LoadGroups() ([]*GroupRow, error)
- func (s *StateDB) LoadInstanceByID(id string) (*InstanceRow, error)
- func (s *StateDB) LoadInstanceChildren(parentID string) ([]*InstanceRow, error)
- func (s *StateDB) LoadInstances() ([]*InstanceRow, error)
- func (s *StateDB) LoadInstancesByGroup(groupPath string) ([]*InstanceRow, error)
- func (s *StateDB) LoadRecentSessions() ([]*RecentSessionRow, error)
- func (s *StateDB) LoadWatcherByID(id string) (*WatcherRow, error)
- func (s *StateDB) LoadWatcherByName(name string) (*WatcherRow, error)
- func (s *StateDB) LoadWatcherEvents(watcherID string, limit int) ([]WatcherEventRow, error)
- func (s *StateDB) LoadWatcherEventsForSession(sessionID string) ([]*WatcherEventRow, error)
- func (s *StateDB) LoadWatchers() ([]*WatcherRow, error)
- func (s *StateDB) LookupWatcherEventSessionByDedupKey(watcherID, dedupKey string) (string, error)
- func (s *StateDB) LookupWatcherIDByDedupKey(dedupKey string) (string, error)
- func (s *StateDB) Migrate() error
- func (s *StateDB) PersistInstanceStatusesTx(updates []InstanceStatusUpdate) error
- func (s *StateDB) PruneStaleSessionClaims() error
- func (s *StateDB) ReadAllStatuses() (map[string]StatusRow, error)
- func (s *StateDB) ReadLastSentAt(id string) (int64, error)
- func (s *StateDB) RegisterInstance(isPrimary bool) error
- func (s *StateDB) ReleaseAllClaims() error
- func (s *StateDB) ReleaseClaims(ids []string) error
- func (s *StateDB) ResignPrimary() error
- func (s *StateDB) SaveGroup(g *GroupRow) error
- func (s *StateDB) SaveGroups(groups []*GroupRow) error
- func (s *StateDB) SaveInstance(inst *InstanceRow) error
- func (s *StateDB) SaveInstances(insts []*InstanceRow) error
- func (s *StateDB) SaveRecentSession(row *RecentSessionRow) error
- func (s *StateDB) SaveWatcher(w *WatcherRow) error
- func (s *StateDB) SaveWatcherEvent(watcherID, dedupKey, sender, subject, routedTo, sessionID, body string, ...) (bool, error)
- func (s *StateDB) SetAcknowledged(id string, ack bool) error
- func (s *StateDB) SetArchived(id string, at time.Time) error
- func (s *StateDB) SetMeta(key, value string) error
- func (s *StateDB) TakeMeta(key string) (string, error)
- func (s *StateDB) Touch() error
- func (s *StateDB) UnregisterInstance() error
- func (s *StateDB) UpdateTitleIfUnlocked(id, title string) (applied bool, err error)
- func (s *StateDB) UpdateWatcherEventRoutedTo(watcherID, dedupKey, routedTo, triageSessionID string) error
- func (s *StateDB) UpdateWatcherEventSessionID(watcherID, dedupKey, sessionID string) error
- func (s *StateDB) UpdateWatcherStatus(watcherID string, status string) error
- func (s *StateDB) UpsertInstances(insts []*InstanceRow) error
- func (s *StateDB) WriteAutoNameDescription(id, description string) error
- func (s *StateDB) WriteClaudeSessionBinding(id, sessionID string, detectedAt time.Time) error
- func (s *StateDB) WriteCodexSessionBinding(id, sessionID string, detectedAt time.Time) error
- func (s *StateDB) WriteGeminiSessionBinding(id, sessionID string, detectedAt time.Time) error
- func (s *StateDB) WriteLastSentAt(id string, unixSeconds int64) error
- func (s *StateDB) WriteStatus(id, status, tool string) error
- type StatusRow
- type WatcherEventRow
- type WatcherRow
Constants ¶
const SchemaVersion = 13
SchemaVersion tracks the current database schema version. Bump this when adding migrations.
Variables ¶
var ErrRefusingEmptySweep = errors.New("statedb: refusing to wipe populated instances table with an empty SaveInstances payload (use ClearAllInstances to intentionally clear)")
ErrRefusingEmptySweep is returned by SaveInstances when it is asked to persist an EMPTY instance set while the instances table still holds rows.
S1 data-loss safeguard (added after the 2026-06-04 incident, the third of its class): SaveInstances' DELETE+re-insert sweep used to run an unconditional `DELETE FROM instances` for an empty payload, so a stray SaveInstances([]) wiped the live profile index. Refusing the destructive empty sweep turns silent data loss into a loud, recoverable error. Callers that genuinely intend to empty the table must use ClearAllInstances.
Functions ¶
func MarshalToolData ¶
func MarshalToolData( claudeSessionID string, claudeDetectedAt time.Time, geminiSessionID string, geminiDetectedAt time.Time, geminiYoloMode *bool, geminiModel string, openCodeSessionID string, openCodeDetectedAt time.Time, codexSessionID string, codexDetectedAt time.Time, latestPrompt string, notes string, loadedMCPNames []string, toolOptionsJSON json.RawMessage, sandboxJSON json.RawMessage, sandboxContainer string, sshHost string, sshRemotePath string, multiRepoEnabled bool, additionalPaths []string, multiRepoTempDir string, multiRepoWorktrees []MultiRepoWorktreeData, channels []string, extraArgs []string, plugins []string, pluginChannelLinkDisabled bool, autoLinkedChannels []string, color string, ) json.RawMessage
func MergeToolDataExtras ¶ added in v1.7.73
func MergeToolDataExtras(oldToolData, newToolData json.RawMessage) json.RawMessage
MergeToolDataExtras preserves any keys in oldToolData that are not part of agent-deck's typed tool_data schema (the toolDataBlob fields in this package) and that are not already present in newToolData. It returns the merged JSON to write back to the instances table.
Why this exists: agent-deck's save path (SaveInstances) builds a fresh tool_data blob from typed Instance fields and INSERT OR REPLACEs the row wholesale. Any externally-written keys not modeled by toolDataBlob are silently dropped on every save cycle. The user-set `clear_on_compact` flag is the canonical example: it has no agent-deck CLI surface, so it is set by direct SQLite UPDATE; without this merge, it survives at most until the next session lifecycle event.
The function is conservative: typed-known keys are not touched (the new blob's value wins, including absence-by-omitempty), and new explicitly setting a key wins over the old value (no silent override of intended updates). Only keys that are completely unknown to the typed schema AND absent from the new blob are carried forward.
EXCEPTION — sticky detected session-id keys (stickyToolDataKeys): the per-tool conversation ids (claude_session_id / gemini_session_id / opencode_session_id / codex_session_id and their *_detected_at) are write-once-per-conversation identity, populated asynchronously after a session starts. They are treated like extras for the ABSENCE case: when the new blob OMITS them (omitempty zero-value) but the old row HAS them, the old value is carried forward, so an unrelated full-table save whose in-memory snapshot simply has not detected the id yet can no longer silently wipe a live session's mapping (t-0133). A NON-EMPTY new value still wins (a real resume/fork that changes the id), and an EXPLICIT empty value present in the new blob (`"claude_session_id":""`) is honored as an intentional clear — only outright OMISSION is treated as "unaware writer, preserve".
func MigrateFromJSON ¶
MigrateFromJSON reads a sessions.json file and inserts all data into the StateDB. Returns the number of instances and groups migrated.
Types ¶
type ClaimRow ¶ added in v1.10.9
type ClaimRow struct {
SessionID string
// OwnerPID is the owning process's OS pid, kept for display/debug only —
// it is not the ownership key (see OwnerToken) because PIDs are recycled
// by the OS.
OwnerPID int
// OwnerToken is the ownership identity: `<pid>-<started-unix>-<random
// hex>`, unique per StateDB.Open() and stable for that process's
// lifetime. Matching/ownership decisions use this field, not OwnerPID.
OwnerToken string
Scope string
Heartbeat int64
}
ClaimRow is one row of the session_claims ownership table.
type CostEventRow ¶ added in v1.9.2
type CostEventRow struct {
ID string
SessionID string
Timestamp string // RFC3339; preserve verbatim — cost_events stores TEXT
Model string
InputTokens int64
OutputTokens int64
CacheReadTokens int64
CacheWriteTokens int64
CostMicrodollars int64
BudgetStopTriggered bool
}
CostEventRow mirrors the cost_events table for raw round-trip operations (e.g., cross-profile migration). The canonical CostEvent type lives in internal/costs, but we keep this minimal struct here so the statedb package can read/write rows without a circular import.
type GroupRow ¶
type GroupRow struct {
Path string
Name string
Expanded bool
Order int
DefaultPath string
// MaxConcurrent caps simultaneous running sessions in this group (v1.9.1).
// 0 = unlimited (legacy default for groups predating this field); 1 = serial
// (default for newly-created groups); N>=2 = bounded parallelism.
MaxConcurrent int
}
GroupRow represents a group row in the database.
type InstanceRow ¶
type InstanceRow struct {
ID string
Title string
ProjectPath string
GroupPath string
Order int
Command string
Wrapper string
Tool string
Status string
TmuxSession string
CreatedAt time.Time
LastAccessed time.Time
ParentSessionID string
IsConductor bool
NoTransitionNotify bool
// TmuxSocketName mirrors Instance.TmuxSocketName (v1.7.50+, issue #687).
// Empty for pre-v1.7.50 rows — those keep targeting the default server
// after upgrade.
TmuxSocketName string
// TitleLocked blocks Claude session-name sync into Title (v1.7.52+, issue #697).
TitleLocked bool
// AutoName marks Title as a machine-generated quick-session handle (v12).
// AutoNameDescription holds the last captured Claude task description so an
// auto-named session can show its meaningful name on reopen even when
// stopped/idle (no live pane title). Both default to zero for legacy rows.
AutoName bool
AutoNameDescription string
WorktreePath string
WorktreeRepo string
WorktreeBranch string
// Account is the per-session named account (v1.9.22+, issue #924). Maps to
// `[profiles.<account>.claude].config_dir` at spawn time and becomes the
// most-specific level in the CLAUDE_CONFIG_DIR resolution chain. Empty
// means "fall through to conductor/group/env/profile/global/default".
Account string
// Pin anchors the session to the top/bottom of its group (pin-sessions
// feature). "", "top", or "bottom"; empty (the column default) means not
// pinned, so legacy rows need no backfill.
Pin string
ToolData json.RawMessage // JSON blob for tool-specific data
// ArchivedAt is non-zero when the session is archived (hidden from active lists).
ArchivedAt time.Time
}
InstanceRow represents a session row in the database.
type InstanceStatusUpdate ¶ added in v1.9.48
InstanceStatusUpdate is one targeted status mutation for PersistInstanceStatusesTx: set instances.status = Status WHERE id = ID. No other column is touched, so a concurrent writer's edits to any other field of the same row are preserved.
type MultiRepoWorktreeData ¶ added in v0.26.2
type MultiRepoWorktreeData struct {
OriginalPath string
WorktreePath string
RepoRoot string
Branch string
}
MarshalToolData creates a tool_data JSON blob from individual fields. This is the forward path: Instance fields -> JSON blob for SQLite storage. MultiRepoWorktreeData holds multi-repo worktree info for serialization.
func UnmarshalToolData ¶
func UnmarshalToolData(data json.RawMessage) ( claudeSessionID string, claudeDetectedAt time.Time, geminiSessionID string, geminiDetectedAt time.Time, geminiYoloMode *bool, geminiModel string, openCodeSessionID string, openCodeDetectedAt time.Time, codexSessionID string, codexDetectedAt time.Time, latestPrompt string, notes string, loadedMCPNames []string, toolOptionsJSON json.RawMessage, sandboxJSON json.RawMessage, sandboxContainer string, sshHost string, sshRemotePath string, multiRepoEnabled bool, additionalPaths []string, multiRepoTempDir string, multiRepoWorktrees []MultiRepoWorktreeData, channels []string, extraArgs []string, plugins []string, pluginChannelLinkDisabled bool, autoLinkedChannels []string, color string, )
UnmarshalToolData extracts individual fields from the tool_data JSON blob. This is the reverse path: JSON blob from SQLite -> individual Instance fields.
type RecentSessionRow ¶ added in v0.20.0
type RecentSessionRow struct {
ID string // SHA-256 dedup key (title+path+tool+group)
Title string
ProjectPath string
GroupPath string
Command string
Wrapper string
Tool string
ToolOptions json.RawMessage // serialized ToolOptionsWrapper
SandboxEnabled bool
GeminiYoloMode *bool
DeletedAt time.Time
}
RecentSessionRow captures the config of a deleted session for quick re-creation.
type StateDB ¶
type StateDB struct {
// contains filtered or unexported fields
}
StateDB wraps a SQLite database for session/group persistence. Thread-safe for concurrent use from multiple goroutines within one process. Multiple OS processes can safely read/write via WAL mode + busy timeout.
func GetGlobal ¶
func GetGlobal() *StateDB
GetGlobal returns the global StateDB instance (may be nil).
func Open ¶
Open creates or opens a SQLite database at dbPath with WAL mode and busy timeout.
busy_timeout and foreign_keys are PER-CONNECTION pragmas in SQLite, so they MUST be passed via the DSN's `_pragma` parameter — setting them once via db.Exec only affects whichever pool connection happened to run the PRAGMA. Pre-fix, fresh connections in the pool defaulted to busy_timeout=0, which turned every transient lock into an immediate SQLITE_BUSY at the application level. journal_mode=WAL is persistent on the database file, so it can stay as a one-shot Exec.
func (*StateDB) AliveInstanceCount ¶
AliveInstanceCount returns how many TUI instances have fresh heartbeats.
func (*StateDB) ClaimSessions ¶ added in v1.10.9
func (s *StateDB) ClaimSessions(ids []string, scope string, staleAfter time.Duration) (map[string]bool, error)
ClaimSessions atomically claims each id for this process. A claim succeeds when the row is absent, already ours (by owner_token), heartbeat-stale, or held with a strictly shorter (less specific) scope — longer group path wins, equal specificity is first-come (no stealing, no flapping). Returns the ids owned by this process after the transaction.
This upsert is also this process's sole heartbeat refresh path for claims: the DO UPDATE branch fires for our own already-owned rows (owner_token match) every sweep, bumping their heartbeat. Invariant: heartbeats are only ever refreshed for rows re-claimed here — never blanket-refreshed by owner — so a claim that stops being re-claimed (failed release, ghost row) goes stale and gets taken over or pruned instead of living forever.
func (*StateDB) CleanDeadInstances ¶
CleanDeadInstances removes heartbeat entries that haven't been updated within timeout.
func (*StateDB) ClearAllInstances ¶ added in v1.9.48
ClearAllInstances is the explicit escape hatch for intentionally emptying the instances table. SaveInstances([]) refuses to wipe a populated table (S1 data-loss safeguard, ErrRefusingEmptySweep); callers that truly mean to clear every row must call this method so the destructive intent is unambiguous and greppable. It is a no-op on an already-empty table.
func (*StateDB) DeleteCostEventsForSession ¶ added in v1.9.2
DeleteCostEventsForSession removes every cost_events row matching session_id.
func (*StateDB) DeleteGroup ¶
DeleteGroup removes a group by path.
func (*StateDB) DeleteGroupSubtree ¶ added in v1.10.9
DeleteGroupSubtree removes a group and all of its descendants by path. Because SaveGroups is additive (upsert, never prune), intentional group removal — delete, rename, move — MUST go through this explicit delete, or the old path rows would linger and resurrect on the next reload.
The LIKE pattern matches only true descendants (path + "/"), so a prefix look-alike such as "parental" survives a delete of "parent".
func (*StateDB) DeleteInstance ¶
DeleteInstance removes an instance by ID.
Wrapped in withBusyRetry because parallel `agent-deck rm` invocations (e.g. xargs -P 14) all contend on the same WAL writer slot. Without retry, transient SQLITE_BUSY silently drops the DELETE while the CLI still reports success — the silent-loss half of issue #909.
func (*StateDB) DeleteInstanceRow ¶ added in v1.9.2
DeleteInstanceRow removes a single row by ID. Cost / watcher rows are NOT touched — those are deleted explicitly by the migration so the orchestrator can sequence cleanup with the target-write phase.
func (*StateDB) DeleteWatcherEventsForSession ¶ added in v1.9.2
DeleteWatcherEventsForSession removes watcher_events rows whose session_id OR triage_session_id matches the given sessionID.
func (*StateDB) ElectPrimary ¶ added in v0.11.1
ElectPrimary attempts to make this instance the primary. Returns true if this instance is now (or already was) the primary. Uses a transaction to atomically clear stale primaries and claim if available.
func (*StateDB) InsertCostEventRow ¶ added in v1.9.2
func (s *StateDB) InsertCostEventRow(ev *CostEventRow) error
InsertCostEventRow inserts a single cost_events row verbatim, preserving id (which is also the dedup key in costs.WriteCostEvent — using INSERT OR IGNORE makes the migration safely retriable).
func (*StateDB) InsertInstanceRow ¶ added in v1.9.2
func (s *StateDB) InsertInstanceRow(inst *InstanceRow) error
InsertInstanceRow inserts (or replaces) a single instance row. Unlike SaveInstance it does not merge tool_data extras — cross-profile migration is a verbatim transfer and the caller has already prepared the row.
func (*StateDB) InsertWatcherEventRow ¶ added in v1.9.2
func (s *StateDB) InsertWatcherEventRow(ev *WatcherEventRow) error
InsertWatcherEventRow inserts a watcher_events row with INSERT OR IGNORE against the (watcher_id, dedup_key) UNIQUE constraint — safe to retry. Note: the source row's `id` (auto-increment) is intentionally NOT preserved; the unique constraint is what dedupes across DBs.
func (*StateDB) InstanceExists ¶ added in v1.9.1
InstanceExists returns true iff a row with the given id is present. Used by the rm path's post-commit verify (issue #909) to detect resurrection by a concurrent SaveInstances rewrite.
func (*StateDB) LastModified ¶
LastModified returns the last_modified timestamp from metadata.
func (*StateDB) LoadClaims ¶ added in v1.10.9
LoadClaims returns all claim rows keyed by session id.
func (*StateDB) LoadCostEventsForSession ¶ added in v1.9.2
func (s *StateDB) LoadCostEventsForSession(sessionID string) ([]*CostEventRow, error)
LoadCostEventsForSession returns every cost_events row matching session_id. Timestamp is preserved verbatim as TEXT — we can't reliably round-trip through time.Time without risking timezone drift.
func (*StateDB) LoadGroup ¶ added in v1.9.2
LoadGroup returns the row for the given path, or (nil, nil) if absent.
func (*StateDB) LoadGroups ¶
LoadGroups returns all groups ordered by sort_order.
func (*StateDB) LoadInstanceByID ¶ added in v1.9.2
func (s *StateDB) LoadInstanceByID(id string) (*InstanceRow, error)
LoadInstanceByID returns the row with the given id, or (nil, nil) if it does not exist. Any other error (driver, schema, etc.) is returned as-is.
func (*StateDB) LoadInstanceChildren ¶ added in v1.9.2
func (s *StateDB) LoadInstanceChildren(parentID string) ([]*InstanceRow, error)
LoadInstanceChildren returns rows whose parent_session_id matches the given id.
func (*StateDB) LoadInstances ¶
func (s *StateDB) LoadInstances() ([]*InstanceRow, error)
LoadInstances returns all instances ordered by sort_order.
func (*StateDB) LoadInstancesByGroup ¶ added in v1.9.2
func (s *StateDB) LoadInstancesByGroup(groupPath string) ([]*InstanceRow, error)
LoadInstancesByGroup returns rows whose group_path exactly matches the given path.
func (*StateDB) LoadRecentSessions ¶ added in v0.20.0
func (s *StateDB) LoadRecentSessions() ([]*RecentSessionRow, error)
LoadRecentSessions returns all recent sessions ordered by most recently deleted.
func (*StateDB) LoadWatcherByID ¶ added in v1.9.2
func (s *StateDB) LoadWatcherByID(id string) (*WatcherRow, error)
LoadWatcherByID returns the watcher row with the given id, or (nil, nil) if absent. Used by cross-profile migration to copy referenced watcher rows from src to dst before inserting watcher_events (the events table FK- references watchers(id) with foreign_keys=on).
func (*StateDB) LoadWatcherByName ¶ added in v1.5.1
func (s *StateDB) LoadWatcherByName(name string) (*WatcherRow, error)
LoadWatcherByName returns the watcher with the given name, or nil if not found. A missing watcher is not an error; (nil, nil) is returned.
func (*StateDB) LoadWatcherEvents ¶ added in v1.5.1
func (s *StateDB) LoadWatcherEvents(watcherID string, limit int) ([]WatcherEventRow, error)
LoadWatcherEvents returns up to limit events for the given watcher, ordered most recent first.
func (*StateDB) LoadWatcherEventsForSession ¶ added in v1.9.2
func (s *StateDB) LoadWatcherEventsForSession(sessionID string) ([]*WatcherEventRow, error)
LoadWatcherEventsForSession returns every watcher_events row whose session_id OR triage_session_id matches sessionID. The dual match preserves triage links when migrating a triage target.
func (*StateDB) LoadWatchers ¶ added in v1.5.1
func (s *StateDB) LoadWatchers() ([]*WatcherRow, error)
LoadWatchers returns all watchers ordered by name.
func (*StateDB) LookupWatcherEventSessionByDedupKey ¶ added in v1.5.1
LookupWatcherEventSessionByDedupKey queries the session_id for a specific event. Returns ("", nil) if no matching event exists or session_id is empty.
func (*StateDB) LookupWatcherIDByDedupKey ¶ added in v1.5.1
LookupWatcherIDByDedupKey returns the watcher_id for the first watcher_events row with the given dedup_key. Returns an error if no row is found. Used by the triageReaper to correlate a result.json back to its originating event (D-08).
func (*StateDB) Migrate ¶
Migrate creates tables if they don't exist and runs any pending migrations.
func (*StateDB) PersistInstanceStatusesTx ¶ added in v1.9.48
func (s *StateDB) PersistInstanceStatusesTx(updates []InstanceStatusUpdate) error
PersistInstanceStatusesTx applies a batch of targeted status updates inside a SINGLE transaction. It is the persistence primitive for `session revive`.
Why a dedicated primitive (two independent guarantees):
ATOMICITY / no partial write. All rows commit together or none do. A mid-loop failure on `revive --all` rolls the whole batch back instead of leaving the table half-healed (some rows StatusRunning, some still StatusError). Per-row INSERT-OR-REPLACE outside a tx could not promise this.
NO clobber of concurrent edits. Each row is written with a TARGETED `UPDATE instances SET status = ? WHERE id = ?` — only the single column revive owns (see Reviver.defaultReviveAction, which mutates nothing but Instance.Status). It deliberately does NOT use INSERT OR REPLACE of the whole row: a full-row write would overwrite every other column from revive's stale in-memory snapshot, clobbering any field (title, group, tool_data, last_accessed, …) a concurrent process edited between revive's load and its save. Mirrors WriteStatus / WriteClaudeSessionBinding, which target single columns for exactly this reason.
There is NO DELETE sweep here — a row absent from `updates` is left entirely untouched, so revive can never drop a session a concurrent `add` inserted after revive loaded its snapshot (the lost-update race this fixes). Rows whose id no longer exists (removed concurrently) simply match zero rows; the UPDATE is a benign no-op, never a resurrection.
The acknowledged-reset mirrors WriteStatus: flipping a row to "running" clears its acknowledged flag so the TUI re-surfaces the freshly-revived session. Wrapped in withBusyRetry because the whole batch is idempotent (targeted UPDATEs to fixed ids), matching SaveInstances' retry rationale.
func (*StateDB) PruneStaleSessionClaims ¶ added in v1.10.9
PruneStaleSessionClaims deletes claims whose session no longer exists in the instances table. Cheap single statement; called periodically so claims for deleted/archived-then-purged sessions cannot accumulate over a long-lived owner process.
func (*StateDB) ReadAllStatuses ¶
ReadAllStatuses returns status + acknowledged flag for every instance.
func (*StateDB) ReadLastSentAt ¶ added in v1.9.67
ReadLastSentAt returns the last_sent_at clock (Unix seconds, 0 if never sent) for a session. Read-only; used by the self-heal detection pass.
func (*StateDB) RegisterInstance ¶
RegisterInstance records this process as an active TUI instance.
func (*StateDB) ReleaseAllClaims ¶ added in v1.10.9
ReleaseAllClaims deletes every claim owned by this process. Shutdown path, same place as ResignPrimary.
func (*StateDB) ReleaseClaims ¶ added in v1.10.9
ReleaseClaims deletes the given claims when owned by this process (sessions that left this instance's scope).
func (*StateDB) ResignPrimary ¶ added in v0.11.1
ResignPrimary clears the is_primary flag for this process.
func (*StateDB) SaveGroups ¶
SaveGroups upserts the given groups in a single transaction. It is ADDITIVE: groups absent from the slice are left untouched, never deleted.
This deliberately abandons the old replace-all (DELETE FROM groups + insert) because a save can run with an incomplete in-memory tree — a stale tree from another concurrent instance (allow_multiple), or the instances-only NewGroupTree fallback. Replace-all then wiped every group the saver didn't know about; populated groups self-healed from their sessions on reload, but empty (session-less) groups were lost forever. With upsert, a partial save can only add/update; intentional removal must go through DeleteGroupSubtree.
func (*StateDB) SaveInstance ¶
func (s *StateDB) SaveInstance(inst *InstanceRow) error
SaveInstance inserts or replaces a single instance.
func (*StateDB) SaveInstances ¶
func (s *StateDB) SaveInstances(insts []*InstanceRow) error
SaveInstances inserts or replaces multiple instances in a single transaction. It also removes any rows from the database that are not in the provided list.
DANGER (#1550): the DELETE-NOT-IN sweep deletes any row a concurrent process inserted after the caller loaded its snapshot. Routine saves must use UpsertInstances instead; SaveInstances is reserved for whole-table replaces where the payload is authoritative (JSON->SQLite migration).
Wrapped in withBusyRetry because parallel writers (CLI + TUI + heartbeat daemons) contend on the WAL writer slot. The whole save is idempotent at the row level (INSERT OR REPLACE + DELETE WHERE NOT IN), so retrying the outer transaction on SQLITE_BUSY is safe. Part of the v1.9.1 #909 fix.
func (*StateDB) SaveRecentSession ¶ added in v0.20.0
func (s *StateDB) SaveRecentSession(row *RecentSessionRow) error
SaveRecentSession inserts or replaces a recent session entry, then prunes to 20.
The INSERT and the prune are bundled in a single transaction so a crash between them cannot leave the table over-budget (the prune always sees the just-inserted row). The whole transaction runs under withBusyRetry to absorb transient SQLITE_BUSY from concurrent writers — pre-fix, these caused user-visible "recent session lost" reports under contention.
func (*StateDB) SaveWatcher ¶ added in v1.5.1
func (s *StateDB) SaveWatcher(w *WatcherRow) error
SaveWatcher inserts or replaces a watcher row.
func (*StateDB) SaveWatcherEvent ¶ added in v1.5.1
func (s *StateDB) SaveWatcherEvent(watcherID, dedupKey, sender, subject, routedTo, sessionID, body string, maxEvents int) (bool, error)
SaveWatcherEvent inserts an event with dedup via INSERT OR IGNORE. Returns true if the row was inserted (new event), false if it was a duplicate. Prunes to maxEvents after successful insert.
Retries on SQLITE_BUSY: concurrent INSERTs across connections can trip the write lock even with WAL + busy_timeout if the driver surfaces BUSY before the backoff completes. Retries are cheap because the operation is idempotent (INSERT OR IGNORE).
func (*StateDB) SetAcknowledged ¶
SetAcknowledged sets or clears the acknowledged flag for an instance.
func (*StateDB) SetArchived ¶ added in v1.10.9
SetArchived sets or clears the archive timestamp for a single instance via a targeted UPDATE. Archive/unarchive mutate one field on one row, so they must NOT go through the full-table saveInstances() path: under concurrent writers that path's external-change guard aborts the save and reloads, silently discarding the archive (see #archive-abort). A scoped UPDATE always lands. A zero `at` clears the flag (unarchive).
The scoped UPDATE bypasses saveInstances(), which is also the only code path that calls Touch(). Touch() explicitly here: it stamps metadata.last_modified, the timestamp StorageWatcher polls to notice out-of-process writes. Without it a running TUI never reloads, and its next forced full-table save replays a stale snapshot over this row, reverting the archive.
func (*StateDB) TakeMeta ¶ added in v1.10.9
TakeMeta reads a metadata value and clears it (sets to "") for consume-once signals (e.g. focus_request). Use it instead of GetMeta-then-SetMeta(""): the clear is a compare-and-clear that only wipes the row when it still holds the value just read, so a newer value written by a concurrent writer between the read and the clear is preserved rather than lost. Returns "" when the key is absent or already empty. (RETURNING isn't usable here — the modernc sqlite driver routes UPDATE through exec and discards RETURNING rows.)
func (*StateDB) Touch ¶
Touch updates a metadata timestamp that other instances can poll to detect changes.
func (*StateDB) UnregisterInstance ¶
UnregisterInstance removes this process from the heartbeat table.
func (*StateDB) UpdateTitleIfUnlocked ¶ added in v1.10.9
UpdateTitleIfUnlocked sets an instance's title (and clears auto_name, since a synced title replaces the auto-generated handle) with a single conditional UPDATE, atomic at the SQL level: the WHERE clause is evaluated by SQLite as part of the same statement, so there is no read-then-write gap a concurrent lock can land in. Returns applied=false (no error) when the row is already locked or doesn't exist — the caller made no change.
This exists for writers that only intend to touch the title based on a possibly-stale decision — namely the hook-triggered Claude title sync (cmd/agent-deck/hook_name_sync.go), which loads a full instance snapshot in a separate process and used to save it back wholesale via UpsertInstances. If a user rename (which sets title_locked) landed between that process's load and its save, the stale wholesale save would silently revert the rename — the "session name keeps getting overwritten" bug. A general storage-layer guard against this is unsafe: an incoming "unlocked" payload is ambiguous between "stale snapshot" (reject) and "deliberate `session set-title-lock off`" (honor), and both look identical as row data. Only the write site itself can resolve that, by asking SQLite to apply the change solely if the row is STILL unlocked at the moment of the write.
func (*StateDB) UpdateWatcherEventRoutedTo ¶ added in v1.5.1
func (s *StateDB) UpdateWatcherEventRoutedTo(watcherID, dedupKey, routedTo, triageSessionID string) error
UpdateWatcherEventRoutedTo updates the routed_to and triage_session_id columns for the row matching (watcher_id, dedup_key). Returns a wrapped error if no row matches (0 rows affected), allowing the caller to distinguish "update OK" from "event not found".
Wrapped in withBusyRetry to match its sister SaveWatcherEvent — both are short idempotent writes against watcher_events called from concurrent engine + triage_reaper paths. Without retry, SQLITE_BUSY from a sister INSERT silently drops the routed_to update and the watcher event sticks in "unrouted" forever.
func (*StateDB) UpdateWatcherEventSessionID ¶ added in v1.5.1
UpdateWatcherEventSessionID sets the session_id on an existing watcher event. Returns an error if no matching row exists (0 rows affected).
func (*StateDB) UpdateWatcherStatus ¶ added in v1.5.1
UpdateWatcherStatus sets the status field on a watcher row. Returns an error if no watcher with the given ID exists.
func (*StateDB) UpsertInstances ¶ added in v1.10.9
func (s *StateDB) UpsertInstances(insts []*InstanceRow) error
UpsertInstances inserts or replaces the given instances WITHOUT the DELETE-NOT-IN sweep: rows absent from the payload are left untouched, so a writer holding a stale snapshot can never delete sessions another process created after that snapshot was loaded (#1550). Deletions must be explicit and targeted: DeleteInstance / RemoveSessionAndVerify (#909), or ClearAllInstances for an intentional full wipe. An empty payload is a no-op.
func (*StateDB) WriteAutoNameDescription ¶ added in v1.9.58
WriteAutoNameDescription persists the latest Claude task description for an auto-named session into the auto_name_description column without a whole-row INSERT OR REPLACE. The background status loop captures the live pane title on its own cadence; none of those ticks run a full Save, so without this targeted write the description would only reach disk on the next user-triggered save — and an app exit before then would lose the name on reopen (the bug this fixes).
Wrapped in withBusyRetry for the same reason as WriteStatus: SQLite serializes writers, so under contention a transient SQLITE_BUSY would otherwise silently drop the update.
func (*StateDB) WriteClaudeSessionBinding ¶ added in v1.9.28
WriteClaudeSessionBinding atomically updates claude_session_id and claude_detected_at inside the tool_data JSON column for the given instance. Used by the hook-rebind path (UpdateHookStatus → bindClaudeSessionFromHook) to persist the new session ID without a whole-row INSERT OR REPLACE — which would clobber any concurrent writes to other tool_data fields by writers holding a stale snapshot of the instance.
PERSIST-12 (see instance.go:bindClaudeSessionFromHook doc comment) originally deferred this to an external "save cycle", but none of the three UpdateHookStatus callers (TUI tick, web refresh, CLI status refresh) actually call Save after rebind. Without this targeted write, tool_data.claude_session_id stays pinned at the pre-/clear UUID indefinitely for any DB-direct consumer (claudopticon, etc.) — and the lifecycle log accumulates fresh "rebind" entries forever because concurrent processes keep reloading the stale row from disk and clobbering the in-memory mutation.
Wrapped in withBusyRetry: SQLite serializes writers through a single write lock, so under contention with WriteStatus / SaveInstance / heartbeat writers a transient SQLITE_BUSY would otherwise drop this update — matching the WriteStatus rationale above.
func (*StateDB) WriteCodexSessionBinding ¶ added in v1.9.29
WriteCodexSessionBinding is the Codex counterpart of WriteClaudeSessionBinding: it atomically rewrites $.codex_session_id and $.codex_detected_at inside the tool_data JSON column without touching any unrelated keys. See WriteClaudeSessionBinding for the full rationale (PERSIST-12, json_set vs. tool_data = ?, withBusyRetry). This sibling exists because the Codex rebind path in bindCodexSessionFromHook has the same in-memory-only mutation shape that the Claude fix in #1140 addressed — tracked as #1139.
func (*StateDB) WriteGeminiSessionBinding ¶ added in v1.9.29
WriteGeminiSessionBinding is the Gemini counterpart of WriteClaudeSessionBinding. See that function's doc comment for the PERSIST-12 / json_set / withBusyRetry rationale; the Gemini rebind path in bindGeminiSessionFromHook had the same persistence gap (#1139).
func (*StateDB) WriteLastSentAt ¶ added in v1.9.67
WriteLastSentAt persists the "we talked to it" clock (Unix seconds) for a session into the last_sent_at column with a targeted single-column UPDATE — never a whole-row INSERT OR REPLACE and never SaveInstances. The keysender stamps this on every delivered injection; the self-heal predicate reads it to measure the idle_at_empty_prompt dwell (#1457-followup, self-heal Stage 1).
Like WriteAutoNameDescription this touches ONLY its own column, so a concurrent writer's edits to any other field of the same row are preserved (no data-loss surface), and it is wrapped in withBusyRetry for the same SQLITE_BUSY reason.
func (*StateDB) WriteStatus ¶
WriteStatus updates the status and tool for an instance.
Wrapped in withBusyRetry: the transition daemon (#755 family) calls this under contention with other writers (heartbeat, status poller, hook handler). Without retry, transient SQLITE_BUSY drops the user-visible status update and the TUI shows stale state.
type WatcherEventRow ¶ added in v1.5.1
type WatcherEventRow struct {
ID int64
WatcherID string
DedupKey string
Sender string
Subject string
RoutedTo string
SessionID string
TriageSessionID string
Body string
CreatedAt time.Time
}
WatcherEventRow represents a single event row from the watcher_events table.