Skip to content

feat: share one state dir between the terminal agent and the desktop app (ATO-416) - #673

Merged
sosidudku1 merged 6 commits into
mainfrom
feat/shared-state-ato416
Oct 15, 2026
Merged

sosidudku1 merged 6 commits into
mainfrom
feat/shared-state-ato416

Conversation

@sosidudku1

Copy link
Copy Markdown
Collaborator

The desktop app is moving onto the terminal agent's own state dir, ~/.atomic-agent, so both share chats, memory, skills, keys and settings (ATO-416). Until now the desktop kept a separate ~/.atomic-agent-desktop, and a user who set the agent up in the terminal found an empty desktop. This PR is the agent side: what the core needs so that two processes can safely run on one state dir, and an importer for the desktop's old data.

What changes

  • Session registry. A process started with ATOMIC_AGENT_SESSION_PARENT_PID=<pid> treats that pid's session marker as its own. The desktop registers one marker for itself and its atag children, so quitting the terminal does not stop a local model the desktop is still using, and the other way round.
  • Compare-and-swap config writes.
    • atag config get --with-sha256 prints {"sha256": "<hex>", "config": {...}} from a single read.
    • atag config set - --expect-sha256 <hex> writes only while config.json still has that hash. Otherwise it writes nothing, prints config changed since it was read and exits 3.
    • Without the flags both commands behave as before.
  • One scheduler per state dir. With several processes on one dir, due tasks run in one of them: the holder of the Telegram lock, otherwise the holder of <stateDir>/scheduler.lock. Claiming a task stays atomic, as before.
  • Whole-file config version. config set - now accepts a payload whose version equals the version already on disk, even when it is newer than the build. Before, a desktop with an older bundled agent could not save any setting after the terminal had been updated. Nested keys from a newer schema can still be lost in that case; that is tracked separately (ATO-417).
  • atag import atomic-desktop --source <dir> [--json] merges the desktop's former state dir into the current one:
    • Sessions and tasks keep their ids and are inserted only where the id is absent, so the target wins.
    • Memory notes are added unless the same text is already stored. Profile facts are added only for keys the target lacks.
    • Lessons, procedures, votes, note links and embeddings are not copied. The embedding backfill rebuilds vectors.
    • The source is read from a private snapshot and never written. A re-run imports nothing new.
    • New store methods back it: SessionStore.insertMissing, TaskStore.insertMissing and MemoryStore.hasContent.

Checks

On the MacBook Pro:

  • lint, build, docs:check, imports:check and typecheck:tests all clean;
  • vitest passes for every touched area;
  • the same commits run in the desktop release branch, with its full vitest (14 288 passed, 0 failed), smoke, e2e and a manual pass on a signed test build. The desktop moved a real ~/.atomic-agent-desktop into ~/.atomic-agent there: 39 chats, 1 task, 8 notes and 5 profile facts were imported, with no duplicates and no warnings.

The desktop side is in a separate PR into rel/xp-build.

…sion registry (ATO-416)

The desktop app and the terminal agent now share one state dir. The
desktop registers its own live-session marker and spawns every atag with
ATOMIC_AGENT_SESSION_PARENT_PID; hasOtherLiveSessions treats that
parent's marker like the caller's own, so an atag the desktop runs no
longer sees the desktop as another app still using the model. A dead
parent's marker is reclaimed as before, and a malformed value is
ignored. The marker format the desktop mirrors is documented in the
module comment.
…set - --expect-sha256 (ATO-416)

The desktop app and the terminal agent now share one state dir, so both
write the same config.json, and a client that writes back a copy it read
earlier silently reverts the other's change.

config get --with-sha256 prints {"sha256", "config"}: the normalised
config and the sha256 of the raw file bytes it came from, from one read
(the empty-string hash when there is no file). config set - --expect-sha256
<hex> writes only while the file still hashes to <hex>; otherwise it
writes nothing, prints "config changed since it was read" to stderr and
exits 3. The hash is checked before the analytics opt-out report and again
right before the write. Without the flag set - is unchanged.
…state dir (ATO-416)

The desktop's atag serve and the TUI now share one state dir, each with a
scheduler. Claiming kept a task from running twice, but it could run in
the process without the Telegram bot, where its report had no channel.

Interval ticks now drain only in the leader. When this process wants
Telegram (a token and the channel not disabled), the live holder of
<stateDir>/telegram.lock leads and also takes scheduler.lock; a process
that lost the bot stands by and gives scheduler.lock up. Otherwise, or
while nobody holds telegram.lock (a reconnect gap), the holder of
<stateDir>/scheduler.lock leads, with the TelegramLockfile take-over:
others wait and take over when the holder exits or its pid is dead.
Leadership is re-checked every tick and released on stop after the drain.
A single process always leads; an explicit tickOnce is not gated; a lock
that cannot be created fails open.
The desktop's bundled atag can be older than the TUI sharing its state
dir. Once the TUI has migrated config.json, config get reports that newer
version and the desktop sends it back unchanged, which config set -
refused as newer than this build understands, blocking every desktop
save. Refuse a payload version only when it is newer than both this
build's schema version and the version currently on disk; the write
already keeps the on-disk version.
…oryStore.hasContent

Seams for merging another state dir's databases (ATO-416):

- SessionStore.insertMissing copies sessions keeping their ids with
  INSERT OR IGNORE, so a stored row wins. No turn_owner is written and a
  live status is ended as an interrupted turn.
- TaskStore.insertMissing does the same for tasks. No run_owner is
  written and a running task arrives pending with no started_at, as the
  boot sweep would put it back.
- MemoryStore.hasContent answers exact-content dedup for the whole
  store, which list() cannot past MEMORY_LIST_MAX_LIMIT rows.

Each insert batch is one BEGIN IMMEDIATE transaction so it waits on a
concurrent TUI or serve instead of failing on a lock upgrade.
atag import atomic-desktop --source <dir> [--json] merges the desktop
app's former state dir into ATOMIC_AGENT_STATE_DIR (ATO-416). The
desktop moves the plain files itself; this handles the databases:

- sessions and tasks by id, target wins on the same id, live turns and
  running tasks arrive ended / pending with no owner marks
- memory notes through MemoryStore with exact-content dedup, keeping
  tags, session, working dir, source and timestamp
- profile facts through ProfileStore, skipped when the target has any
  row for the key
- lessons, procedures, votes, note links and embeddings are not copied

Each source database is read from a private snapshot: a byte copy after
a clean close, SQLite's online backup over a read-only connection when a
-wal is present. The source is never written. Parts run independently,
a re-run imports nothing new, and --json prints one object with
per-part imported/skipped counts, warnings and error on failure.
@sosidudku1
sosidudku1 merged commit 79e628a into main Oct 15, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant