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.
Only the codes this command can produce. The full contract is on the commands index.
0ok
2usage
10contract unmet
12missing 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
exit0
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
exit0
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
exit0
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
exit2
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 --checkepisodes — 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 candidatecheck: 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.