Skip to content

majordomus recover

Close episodes nobody ended, fold duplicate records, classify the stores' stray files.

Close episodes nobody ended, fold duplicate records, classify the stores' stray files.

  • state-mutating
  • system

Recurring maintenance, unlike migrate, which is the one-time move off the pre-.ai layout. Every subject is idempotent and every one has a --check that writes nothing. Nothing is deleted for being unknown - a candidate whose evidence cannot be read is reported, counted and left alone.

Syntax

majordomus recover [status|episodes|records|orphans|all] [--check] [--older-than <duration>]

Lifecycle

  1. Set up
  2. Begin
  3. Work
  4. Verify
  5. Conclude
  6. Inspect

Other commands at this stage:init, update, migrate.

Reads

  • .ai/repo/policy.yaml
  • .ai/local/state/sessions-open/
  • .ai/local/state/ledger.jsonl
  • .ai/repo/sessions/
  • .ai/local/state/checkpoints/
  • .ai/local/state/handovers/

Writes

  • .ai/repo/sessions/
  • .ai/local/state/sessions-open/
  • .ai/local/state/ledger.jsonl

Exit codes

Only the codes this command can produce. The full contract is on the commands index.

  • 0 ok
  • 2 usage
  • 10 contract unmet
  • 12 missing artifact

An episode is closed by evidence, or not at all

A provider that is killed sends no end event, and the episode it opened stays open for ever. Recovery closes it — but only when the evidence says its client is gone, and never by guessing. Every subject has a --check that writes nothing, and the measurement that decided each verdict is printed before anything is done with it.

The plan, and the measurement behind it

one episode stranded and one live
yes

Given this repository

# Installed, with two episodes open: one whose client is gone and one that is live.
#
# Both halves of the stranded episode's trail are aged, not only its open record. The last
# sign of life is the later of started_at and the newest ledger line the episode stamped, so
# an episode whose ledger is fresh is young however old its file claims to be — a fixture
# that moved one half would demonstrate the command passing for the wrong reason.
. "$FIXTURE_SETUP/installed.sh"
"$MJ" session start --provider-session gone-client >/dev/null
"$MJ" session start --provider-session this-client >/dev/null
_S=.ai/local/state
_dead="$(sed -n 's/^session_id: //p' "$_S/sessions-open/gone-client.yaml" | head -n 1)"
sed -i.bak 's/^started_at: .*/started_at: 2020-01-01T00:00:00Z/' "$_S/sessions-open/gone-client.yaml"
rm -f "$_S/sessions-open/gone-client.yaml.bak"
awk -v s="$_dead" '{ if (index($0, "\"session\":\"" s "\"") > 0) sub(/"ts":"[^"]*"/, "\"ts\":\"2020-01-01T00:00:00Z\""); print }' \
  "$_S/ledger.jsonl" > "$_S/ledger.mj-tmp" && mv "$_S/ledger.mj-tmp" "$_S/ledger.jsonl"

Run

$ majordomus recover episodes --check

Output contains

  • last seen 2020-01-01T00:00:00Z
  • stranded:
  • live: this process is inside it
  • check: 1 action

exit 0

Every candidate's last sign of life is printed, including the ones nothing will happen to, so a wrong predicate is visible in the output rather than in its consequences. The dry run writes nothing at all.

Closing the one whose client is gone

one episode stranded and one live
yes

Given this repository

# Installed, with two episodes open: one whose client is gone and one that is live.
#
# Both halves of the stranded episode's trail are aged, not only its open record. The last
# sign of life is the later of started_at and the newest ledger line the episode stamped, so
# an episode whose ledger is fresh is young however old its file claims to be — a fixture
# that moved one half would demonstrate the command passing for the wrong reason.
. "$FIXTURE_SETUP/installed.sh"
"$MJ" session start --provider-session gone-client >/dev/null
"$MJ" session start --provider-session this-client >/dev/null
_S=.ai/local/state
_dead="$(sed -n 's/^session_id: //p' "$_S/sessions-open/gone-client.yaml" | head -n 1)"
sed -i.bak 's/^started_at: .*/started_at: 2020-01-01T00:00:00Z/' "$_S/sessions-open/gone-client.yaml"
rm -f "$_S/sessions-open/gone-client.yaml.bak"
awk -v s="$_dead" '{ if (index($0, "\"session\":\"" s "\"") > 0) sub(/"ts":"[^"]*"/, "\"ts\":\"2020-01-01T00:00:00Z\""); print }' \
  "$_S/ledger.jsonl" > "$_S/ledger.mj-tmp" && mv "$_S/ledger.mj-tmp" "$_S/ledger.jsonl"

Run

$ majordomus recover episodes

Output contains

  • .ai/repo/sessions/
  • recovered: 1 action

exit 0

The record carries what the open record and the episode's own ledger lines prove. Its commits and changed_files are the explicit empty list and it states no head: the working tree at recovery time belongs to whoever is working now, and stamping it onto an episode that stopped days ago would be a record that looks exactly like a true one.

With nothing degraded

one episode stranded and one live
no

Given this repository

# A repository with Majordomus installed and projections generated, and one commit of work.
"$MJ" init >/dev/null
"$MJ" update >/dev/null
mkdir -p lib docs
echo a > lib/a
echo d > docs/d
git add . && git commit -qm base

Run

$ majordomus recover

Output contains

  • nothing to recover

exit 0

Absence is an answer. A clean store exits 0 and says so, rather than treating having nothing to do as a failure.

A subject this command does not have

one episode stranded and one live
no

Given this repository

# A repository with Majordomus installed and projections generated, and one commit of work.
"$MJ" init >/dev/null
"$MJ" update >/dev/null
mkdir -p lib docs
echo a > lib/a
echo d > docs/d
git add . && git commit -qm base

Run

$ majordomus recover everything

Output contains

  • unknown subject

exit 2

A misspelled subject is refused rather than folded into the default. A recovery command that silently did something other than what was asked is the one that must not exist.

Reference

Bring this checkout's record stores back to what the contract describes: close episodes nobody ended, fold duplicate records of one episode into one, and classify the stray files the stores accumulate. Recurring, idempotent maintenance — unlike migrate, which is the one-time move off the pre-.ai layout and refuses to run twice.

Subjects: status (the default, read-only), episodes, records, orphans, all.

Reads: .ai/local/state/sessions-open/, .ai/local/state/ledger.jsonl, the closed record store, and the checkpoint and handover stores. Writes: a closed record per recovered episode under .ai/repo/sessions/, the folded record where duplicates existed, a session.recovered ledger line per action, and the removal of the open records and stray temps it acted on.

Behaviour:

  • --check prints the plan and the evidence behind it and writes nothing. The same measurement drives the real run, so the plan is the action.
  • A stray file is judged by its age before its content: a publish temp seconds old belongs to a session close running now, and a .mj-stage.XXXXXX minutes old to a scripts/derive running now. Both are reported as live and neither is touched; one whose age cannot be read is skipped and counted.
  • A stale staging directory is reported and never removed. This command deletes no directory tree — SECURITY.md states "no recursive deletion" — so a .mj-stage.XXXXXX past the threshold is named, counted as skipped, and left for a person to remove.
  • An episode is stranded when its last sign of life — the later of its own started_at and the newest ledger line it stamped — is older than session.stranded_after. --older-than <duration> overrides the policy for one run.
  • Two guards stand between a live episode and the sweep, and neither is the other's backstop. The episode this process resolves to is excluded before any predicate runs; and a candidate whose evidence cannot be read as a timestamp is skipped and counted, never treated as the oldest in the set (project.destructive-sweeps-fail-closed). An episode belonging to another worktree is reported and never closed here.
  • A recovered record carries what the open record and the episode's own ledger lines prove. commits and changed_files are the explicit empty list and head and working_tree are absent: they describe a close that never happened, and the working tree at recovery time belongs to whoever is working now. outcome is interrupted, which is what it was.
  • Duplicate records fold into the oldest by created_at, with the union of the lists the others prove, and the superseded file names recorded in the survivor's body. Records that disagree about the episode's identity or times are reported and nothing is folded.
  • Nothing is deleted for being unrecognised. A publish temp past the threshold holding the only copy of a record is published; one whose episode is already published, or which is empty, is removed; one holding content this version cannot classify is left exactly where it is and counted. A directory somebody put in the checkpoint store is named, measured and left.
  • Idempotent. Running a subject twice takes its actions once, and running it after a crash mid-run takes the ones that did not happen: the record is written before the open file is removed, so an interruption between them costs nothing.
$ majordomus recover episodes --check
episodes — open episodes in .ai/local/state/sessions-open
  s-20260910194205-72ea  key=187c9e1e-…  last seen 2026-09-10T19:42:05Z (ledger, 13h ago)
    stranded: 13h ago, over the 12h threshold; close with outcome interrupted
  s-20260911063532-b41b  key=c93d195f-…  last seen 2026-09-11T08:31:02Z (ledger, 0m ago)
    live: this process is inside it; never a candidate

check: 1 action(s) would be taken and nothing was written (run: majordomus recover all)

Evidence

Every case below runs in CI on Linux and macOS. The site refuses to build if a public command has no behavioural case, no negative case, or no demonstration.

Demonstration

Tested by test/cases/34_command_fixtures.sh, which replays the fixture and compares it with what the command prints.

Searches the titles and descriptions of every page this site publishes.