Skip to content
Cockpit0.29.0
A snapshot: read at commit 707a04c7ce8 on 2026-10-18T00:06:01Z by majordomus 0.29.0. Nothing on this page is read again. See it live

lifecycle.recovery

What the store needs somebody to do

What it is

Open records that can no longer close themselves, temporary files a killed close left in the tracked sessions section, whether the pointer has been migrated to the per-provider layout, and the arithmetic that says whether every episode the ledger has seen start is accounted for. Every finding carries the command that clears it.

Descriptor

Canonical id
lifecycle.recovery
Module
lifecycle
Kind
query
Stability
behaviorallyverified
Provenance
declaredcached
Source
apps/majordomus-cli/src/capability/builtin/lifecycle.rs
Cache
process, 2 entries, 2s
Benchmark
required
Tags
continuitysessionrecovery

Run

query
A query. It reads and changes nothing.

This capability takes no input.

GET /api/v1/lifecycle/recovery

Projections

InterfaceAsWhere
MCP toolmajordomus_lifecycle_recovery-
MCP resourcemajordomus://lifecycle/recovery-
HTTPGET /api/v1/lifecycle/recoverySwagger
Command linenot exposed-

Schemas

Input · Empty
{
  "type": "object",
  "additionalProperties": false,
  "description": "No input.",
  "title": "Empty"
}
Output · Recovery
{
  "type": "object",
  "properties": {
    "present": {
      "type": "boolean",
      "description": "Whether the local half exists at all."
    },
    "pointer": {
      "$ref": "#/$defs/Pointer",
      "description": "The pointer and its layout."
    },
    "stranded": {
      "type": "array",
      "items": {
        "$ref": "#/$defs/Stranded"
      },
      "description": "Episodes that can no longer close themselves."
    },
    "orphans": {
      "type": "array",
      "items": {
        "$ref": "#/$defs/Orphan"
      },
      "description": "Temporary files left in the tracked sessions section."
    },
    "balance": {
      "$ref": "#/$defs/Balance",
      "description": "The started/closed/open arithmetic."
    },
    "findings": {
      "type": "array",
      "items": {
        "type": "string"
      },
      "description": "Everything above, said once, for a reader that wants the summary. Empty is the good\ncase and means there is nothing to recover."
    }
  },
  "required": [
    "present",
    "pointer",
    "balance"
  ],
  "description": "What this checkout's session store needs somebody to do.\n\n`findings` is the whole report said once, and an empty `findings` is the verdict: there\nis nothing to recover. That is the field to branch on — the three detailed lists and the\narithmetic are what a reader consults *after* deciding there is something to look at,\nand each of them can be empty for a good reason while another is not.\n\n```\nuse majordomus_cli::capability::builtin::lifecycle::Recovery;\nuse serde_json::json;\n\n// a healthy checkout: the current layout, nothing stranded, the arithmetic agreeing\nlet clean: Recovery = serde_json::from_value(json!({\n    \"present\": true,\n    \"pointer\": {\"layout\": \"pointer\", \"target\": \"sessions-open/c.yaml\",\n                \"resolves\": true, \"session_id\": \"s-1\", \"note\": \"resolves\"},\n    \"balance\": {\"started\": 8, \"closed\": 7, \"open\": 1,\n                \"unaccounted\": 0, \"agrees\": true, \"note\": \"they agree\"},\n})).unwrap();\n\nassert!(clean.findings.is_empty(), \"an empty findings list is the good verdict\");\nassert!(clean.stranded.is_empty() && clean.orphans.is_empty());\nassert!(clean.balance.agrees);\n\n// a fresh clone has no local half at all, which is also nothing to recover\nlet fresh: Recovery = serde_json::from_value(json!({\n    \"present\": false,\n    \"pointer\": {\"layout\": \"absent\", \"resolves\": false, \"note\": \"no episode is open here\"},\n    \"balance\": {\"started\": 0, \"closed\": 0, \"open\": 0,\n                \"unaccounted\": 0, \"agrees\": true, \"note\": \"nothing has happened yet\"},\n})).unwrap();\nassert!(!fresh.present && fresh.findings.is_empty());\n```",
  "title": "Recovery",
  "$defs": {
    "Pointer": {
      "type": "object",
      "properties": {
        "layout": {
          "$ref": "#/$defs/PointerLayout",
          "description": "The layout."
        },
        "target": {
          "type": "string",
          "description": "What the symlink names, as written, when it is one."
        },
        "resolves": {
          "type": "boolean",
          "description": "Whether reading through it yields an episode."
        },
        "session_id": {
          "type": "string",
          "description": "The episode it resolves to."
        },
        "note": {
          "type": "string",
          "description": "What that means, in words."
        }
      },
      "required": [
        "layout",
        "resolves",
        "note"
      ],
      "description": "What the pointer is and whether it leads anywhere.\n\n`layout` and `resolves` are independent, and the interesting case is the pair\n`Pointer` + `resolves: false`: a symlink that survived the episode it named, which is\nwhat a killed close or a hand-removed record leaves behind. The layout is right, the\ndestination is gone, and `continuity.state` has nothing to report while the store may\nstill hold open episodes — so this is a dangling pointer, not an empty store.\n\n```\nuse majordomus_cli::capability::builtin::lifecycle::{Pointer, PointerLayout};\nuse serde_json::json;\n\n// the healthy case: the current layout, and it leads to an episode\nlet live: Pointer = serde_json::from_value(json!({\n    \"layout\": \"pointer\",\n    \"target\": \"sessions-open/claude-abc.yaml\",\n    \"resolves\": true,\n    \"session_id\": \"s-0002\",\n    \"note\": \"the pointer resolves to an open episode\",\n})).unwrap();\nassert_eq!(live.layout, PointerLayout::Pointer);\nassert!(live.resolves && !live.session_id.is_empty());\n\n// and the dangling one: the right layout, aimed at a record that is no longer there\nlet dangling: Pointer = serde_json::from_value(json!({\n    \"layout\": \"pointer\",\n    \"target\": \"sessions-open/claude-gone.yaml\",\n    \"resolves\": false,\n    \"note\": \"the symlink outlived the episode it named\",\n})).unwrap();\nassert_eq!(dangling.layout, PointerLayout::Pointer, \"the layout is not the fault\");\nassert!(!dangling.resolves);\nassert!(dangling.session_id.is_empty(), \"there is no episode to name\");\n```"
    },
    "PointerLayout": {
      "oneOf": [
        {
          "type": "string",
          "const": "pointer",
          "description": "A symlink into `sessions-open/`: the per-provider layout."
        },
        {
          "type": "string",
          "const": "inline",
          "description": "A regular file: the pre-migration layout, one episode per checkout."
        },
        {
          "type": "string",
          "const": "absent",
          "description": "Nothing is there. No episode is open here, which is not a fault."
        }
      ],
      "description": "The layout of the pointer, which is how far the per-provider migration has got here.\n\nBefore the episode was keyed by its provider session, `session-current.yaml` *was* the\nopen record: one regular file, one episode per checkout. Seven concurrent sessions on\n2026-09-09 produced one record between them under that layout. A regular file here today\nis a checkout that has not run the migration, and its episodes are being overwritten.\n\nOnly one of the three is a finding, and it is the middle one. `Pointer` is the current\nlayout and `Absent` means no episode is open here — a fresh clone, or a worker between\nepisodes — while `Inline` is a checkout still on the pre-migration layout, where a\nsecond concurrent session silently overwrites the first one's record. So the question a\nreader asks of this value is not \"which layout\" but \"is it `Inline`\".\n\n```\nuse majordomus_cli::capability::builtin::lifecycle::PointerLayout;\n\n// the two that are fine, and the one that loses records\nlet loses_records = |l: PointerLayout| l == PointerLayout::Inline;\nassert!(!loses_records(PointerLayout::Pointer));\nassert!(!loses_records(PointerLayout::Absent), \"no episode open is not a fault\");\nassert!(loses_records(PointerLayout::Inline));\n\n// serialised as the word `as_str` gives\nassert_eq!(\n    serde_json::to_value(PointerLayout::Absent).unwrap(),\n    serde_json::json!(PointerLayout::Absent.as_str()),\n);\n```"
    },
    "Stranded": {
      "type": "object",
      "properties": {
        "session_id": {
          "type": "string",
          "description": "The episode id."
        },
        "path": {
          "type": "string",
          "description": "The open record, repository-relative."
        },
        "reason": {
          "type": "string",
          "description": "Why it is stranded."
        },
        "remedy": {
          "type": "string",
          "description": "The command that clears it. A finding with no remedy is a complaint."
        }
      },
      "required": [
        "session_id",
        "path",
        "reason",
        "remedy"
      ],
      "description": "An open episode that cannot close itself, and what to do about it.\n\nThe remedy here is the mechanical one — remove the file — because it is correct in every\ncheckout and needs nothing that is not already installed. `lib/recover.sh` on\n`feature/session-store-recovery` is the same three degradations seen from the writing\nside: it classifies each candidate by reading it, prints the evidence before acting, and\ncloses a stranded episode into a real record rather than deleting it. When that lands,\nthe remedy a finding carries should become `majordomus recover episodes` and the two\ndetections should become one — a reader and a writer that each decide for themselves\nwhat \"stranded\" means are two definitions, and this repository has been bitten by that\nshape often enough to name it.\n\n```\nuse majordomus_cli::capability::builtin::lifecycle::Stranded;\nuse serde_json::json;\n\nlet s: Stranded = serde_json::from_value(json!({\n    \"session_id\": \"s-0007\",\n    \"path\": \".ai/local/state/sessions-open/claude-7.yaml\",\n    \"reason\": \"the worktree it opened in is gone from disk\",\n    \"remedy\": \"rm .ai/local/state/sessions-open/claude-7.yaml\",\n})).unwrap();\n\n// the reason is about the world and the remedy is a command: a finding with no\n// remedy is a complaint, so both are required fields rather than optional ones\nassert!(!s.reason.is_empty() && !s.remedy.is_empty());\nassert!(s.remedy.contains(&s.path), \"the remedy acts on the file it names\");\n```"
    },
    "Orphan": {
      "type": "object",
      "properties": {
        "path": {
          "type": "string",
          "description": "Repository-relative path."
        },
        "bytes": {
          "type": "integer",
          "format": "uint64",
          "minimum": 0,
          "description": "Its size in bytes, which is how a reader tells an empty stub from a lost record."
        }
      },
      "required": [
        "path",
        "bytes"
      ],
      "description": "A temporary file left in a record directory by a close that did not finish.\n\nThe close path writes the durable record to `.tmp.XXXX` in the destination directory and\nrenames it, so that no reader ever sees half a record. A process killed between the two\nleaves the temporary file behind, where it is untracked, invisible to every reader of the\nsection, and — because the directory is tracked — offered to the next `git add .`.\n\n`bytes` is carried because it is the one thing that decides what to do with the file: a\nzero-length temporary file is a close that died before it wrote anything and can simply\ngo, while a large one is a record that was written and never renamed, and is worth\nreading before it is deleted.\n\n```\nuse majordomus_cli::capability::builtin::lifecycle::Orphan;\nuse majordomus_cli::order::canonical;\n\nlet mut orphans = vec![\n    Orphan { path: \".ai/repo/project/sessions/.tmp.b2\".into(), bytes: 4_812 },\n    Orphan { path: \".ai/repo/project/sessions/.tmp.a1\".into(), bytes: 0 },\n];\ncanonical(&mut orphans);\n\n// filed under the path, which names one file and so makes the list total\nlet paths: Vec<&str> = orphans.iter().map(|o| o.path.as_str()).collect();\nassert_eq!(paths, [\n    \".ai/repo/project/sessions/.tmp.a1\",\n    \".ai/repo/project/sessions/.tmp.b2\",\n]);\n\n// and the size is what tells a dead stub from a record that was written\nassert_eq!(orphans[0].bytes, 0, \"nothing was written before the close died\");\nassert!(orphans[1].bytes > 0, \"read this one before removing it\");\n```"
    },
    "Balance": {
      "type": "object",
      "properties": {
        "started": {
          "type": "integer",
          "format": "uint",
          "minimum": 0,
          "description": "`session.started` lines in the ledger."
        },
        "closed": {
          "type": "integer",
          "format": "uint",
          "minimum": 0,
          "description": "`session.closed` lines."
        },
        "open": {
          "type": "integer",
          "format": "uint",
          "minimum": 0,
          "description": "Open records in the store."
        },
        "unaccounted": {
          "type": "integer",
          "format": "int64",
          "description": "`started - closed - open`. Zero is the good case; negative means the ledger was\nrotated under the records, which is ordinary."
        },
        "agrees": {
          "type": "boolean",
          "description": "Whether the three agree."
        },
        "note": {
          "type": "string",
          "description": "What the numbers mean, in words."
        }
      },
      "required": [
        "started",
        "closed",
        "open",
        "unaccounted",
        "agrees",
        "note"
      ],
      "description": "What the ledger says about episode lifecycle against what the store holds.\n\nThe invariant ADR 0052 asks for, stated as arithmetic a person can check: every episode\nthat started either closed or is still open. When `started` exceeds `closed + open`, the\ndifference is episodes whose close was never recorded — the shape of the outage that ADR\ndescribes, where events kept arriving and the records they should have produced did not.\n\nThe sign matters and the two directions mean opposite things. Positive `unaccounted` is\nthe outage: episodes that started, are not open, and produced no closed record — work\nthat happened and left nothing behind. Negative is ordinary bookkeeping: the ledger was\nrotated, or a record was committed from a checkout whose ledger lines are not in this\none, so there are more records than started lines to account for them.\n\n```\nuse majordomus_cli::capability::builtin::lifecycle::Balance;\nuse serde_json::json;\n\nlet of = |started, closed, open, unaccounted, agrees| -> Balance {\n    serde_json::from_value(json!({\n        \"started\": started, \"closed\": closed, \"open\": open,\n        \"unaccounted\": unaccounted, \"agrees\": agrees, \"note\": \"as measured\",\n    })).unwrap()\n};\n\n// the good case: every episode that started either closed or is still open\nlet sound = of(40, 37, 3, 0, true);\nassert_eq!(sound.started as i64 - sound.closed as i64 - sound.open as i64, sound.unaccounted);\nassert!(sound.agrees);\n\n// the outage ADR 0052 describes: four closes that were never recorded\nlet lost = of(40, 33, 3, 4, false);\nassert!(lost.unaccounted > 0 && !lost.agrees);\n\n// and a rotated ledger, which is not a fault in either half\nlet rotated = of(2, 37, 3, -38, false);\nassert!(rotated.unaccounted < 0, \"more records than lines to account for them\");\n```"
    }
  }
}

Examples

These are the capability's own benchmark cases: the same inputs the benchmark runs and the OpenAPI document shows.

default
{}