Skip to content

feat(session): make Emacs restart non-lossy for parallel agent sessions - #485

Draft
tninja wants to merge 3 commits into
mainfrom
kang_feat_11
Draft

feat(session): make Emacs restart non-lossy for parallel agent sessions#485
tninja wants to merge 3 commits into
mainfrom
kang_feat_11

Conversation

@tninja

@tninja tninja commented Aug 18, 2026

Copy link
Copy Markdown
Owner

Problem

When I run 2-4 agent sessions across worktrees, restarting Emacs used to lose the
task ↔ session mapping. The task file's #+SESSION_ID: line literally asked me to
go find the id by hand (<Usually you can get the session id with /status ...>),
and the dashboard couldn't distinguish "this session finished" from "I have no
signal from this terminal" — both looked idle, which made the dashboard actively
misleading at exactly the moment I needed it.

Approach

Three new modules, split along the three separable responsibilities rather than
growing ai-code-session.el:

  • ai-code-session-state.el (100 lines) — derive terminal-neutral state
  • ai-code-session-store.el (306) — persist state in the task org file
  • ai-code-session-restore.el (316) — discover backend session ids and resume

Emacs now reads the backend's own session store (Claude's
~/.claude/projects/<slug>/<uuid>.jsonl, Codex's rollout-*.jsonl session_meta)
to discover the session id and records it into the task file, so J from the menu
or R in the dashboard resumes through the backend's native --resume. Persistence
lives in the task file (#+SESSION_ID: mirror + an * AI Sessions PROPERTIES
section) — no new sidecar state file. Reading a session store is strictly
read-only: nothing under ~/.claude or ~/.codex is created, modified, or deleted.

State is honest tri-state instead of an optimistic guess: OSC 133 / OSC 9;4 signals
report provenance as osc133, vterm/eat output timing reports as idle?, and no
signal at all renders as unknown / -. "No signal" is never dressed up as idle.

Note this stays an observation layer — no agent loop, compaction, context reset,
or subagent orchestration moves into Emacs. That harness belongs inside the agent.

Verification

One entry point reruns everything: bash test/run_ai_code_session_state_gauntlet.sh
(focused tests → full suite → byte compile → checkdoc → whitespace → real execution
→ mutation; non-zero exit on the first broken layer).

  • Focused 48/48 · Full suite 1453 tests, 8 unexpected — byte-identical to the
    HEAD failure set, verified by building a real baseline in a detached
    git worktree and diffing the sorted names
  • Real execution 51/51 against a real sleep process, real clock, real git worktree list, a real Org file, and the live ~/.claude/~/.codex stores. This
    layer earned its keep: it caught two defects all 30 synthetic scenarios missed —
    backends are lazily required, so resume hit user-error ("Backend claude cannot be resumed automatically") in a freshly started Emacs (the exact situation restore
    exists for); and a failed id lookup rescanned the store on every chunk of
    streaming output (measured 0.076 s per miss). Both fixed in product code via
    RED→GREEN, each pinned by a mutant.
  • Mutation 8/8 killed · checkdoc clean · byte compile 0 new warnings
    (whole-package count identical to HEAD's 1, same location)
  • Read-only enforcement is proven by an advice tripwire on 10 mutating primitives.
    To avoid a guard that can never fire counting as evidence, I deliberately made it
    fire once with a probe file, confirmed it was caught, then removed it — no residue.

Existing session APIs, dashboard keys, column meaning/order, and task-header order
are unchanged; the new columns and R are additive. No new package dependency.

Full evidence report, including the honest baseline correction (32 → 8 under this
entry point) and remaining risks, is in
docs/plans/2026-08-17-session-state-evidence.md.

Reviewer notes / test plan

  • package-lint was not run locally (not installed here) — relying on the
    melpazoid CI workflow for that layer
  • The 8 pre-existing full-suite failures are still red; unrelated to this change
    but not fixed here either
  • Backend store formats are observed, not contracted. Degradation is tested
    (unknown backend / malformed file → nil), so an upstream format change falls
    back to the portable agent handoff rather than breaking
  • ai-code-backends-infra-session-uuid-retry-interval defaults to 10s — worth a
    sanity check that this feels right in practice

Kang Tu added 3 commits August 17, 2026 20:11
At 2-4 parallel worktrees, an Emacs restart used to lose the mapping from
task to agent session: the task file asked the user to paste a session id
by hand, and the dashboard had no way to tell a finished session from one
it simply had no signal for.

Three new modules split the three separable responsibilities:

- ai-code-session-state.el   derive terminal-neutral state
- ai-code-session-store.el   persist state in the task org file
- ai-code-session-restore.el discover backend session ids and resume

Emacs now discovers the backend session id from the backend's own session
store and records it in the task file, so `J` / dashboard `R` can resume
through the backend's native --resume. Reading a session store is strictly
read-only: nothing under ~/.claude or ~/.codex is created, modified, or
deleted.

State is honest tri-state: OSC 133 signals report as `osc133`, output
timing reports as `idle?`, and no signal renders as `unknown` / `-`
rather than being passed off as idle.

Existing session APIs, dashboard keys, column order, and task header
order are unchanged; new columns and the `R` key are additive. No new
package dependency.
…k file

Session state recording shipped on by default and assumed one task file
described one backend.  Both were wrong.

Recording writes to a task file, scans ~/.claude and ~/.codex, and
publishes terminal output timing.  Those are side effects outside the
buffer the user is editing, so they are now gated behind
`ai-code-session-tracking-enabled', which defaults to nil.  The gate
covers automatic writes only: the restore commands (`C-c a J' and the
dashboard's `R') keep working, and the dashboard keeps its State and
Signal columns, which already report `unknown' honestly when nothing is
observed.  Reading a backend store stays read-only either way.

A task file is per task, not per backend, so claude and codex can now be
driven from one file in one worktree.  Before a session id exists an
entry is identified by worktree *and* backend, so the second launch adds
a record instead of overwriting the first, and discovering one backend's
id upgrades only that entry.  Restore prompts with `completing-read' when
a file records more than one session and resumes directly when it records
exactly one; the dashboard's `R' passes the row's backend so it never
needs to ask.
…y worktree

Two defects that code review of the opt-in change found.

Turning tracking off froze the last output-timing observation instead of
clearing it, because metadata merging only overwrites and never clears.
The heuristic then aged that leftover timestamp past its delay and the
dashboard reported `idle' -- a state no terminal ever observed.  A signal
nobody refreshes is not evidence, so the timing observation is now
dropped while the flag is off and the dashboard says `unknown'.  The
Ghostel signal is deliberately kept: it reports observed command
boundaries from hooks that stay live, so suppressing it would hide a true
state rather than an inferred one.  The state module stays flag-free; the
policy lives with the caller that owns the flag.

Requiring backend equality to match a worktree-only entry was too strict
and regressed entries recorded before backends were written: an entry with
no `:BACKEND:' could never be upgraded in place, so discovering its
session id appended a duplicate record.  A backend named on only one side
now agrees with the unnamed side, while two named backends still must be
equal, which is what keeps claude and codex in one worktree separate.
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