feat(maestro): register dormant sessions on recovery
A host reboot kills the tmux server, so `SessionManager::recover` iterated an empty list and every session vanished from the API while its sidecar sat untouched on disk. A second recovery pass now reads the sidecars tmux had nothing to adopt and registers each as `SessionStatus::Dormant` — a record of what was running, with no pane and no PTY bridge behind it. `SessionStatus` deserializes leniently now, like `ActivityState`: an unknown spelling reads as `Dead` rather than failing the whole `Session`. Refs #162 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
8325bc6d9a
commit
72644dd564
@@ -414,6 +414,19 @@ describe("session cards (#107)", () => {
|
||||
expect(line2?.textContent).toContain("exited");
|
||||
});
|
||||
|
||||
// #162: a session recovered with no tmux behind it is known but not running,
|
||||
// and never reads as something that exited.
|
||||
it("shows dormant on line 2 for dormant sessions", () => {
|
||||
const s = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000118",
|
||||
status: "Dormant",
|
||||
});
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
|
||||
expect(line2?.textContent).toContain("dormant");
|
||||
expect(line2?.textContent).not.toContain("exited");
|
||||
});
|
||||
|
||||
it("carries the state word only in tooltips and aria labels, never as text", () => {
|
||||
const s = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000019",
|
||||
|
||||
@@ -113,6 +113,9 @@ function activityRailMarkup(slug: string, stateWord: string): string {
|
||||
|
||||
function metaLine(s: Session, now: number): string {
|
||||
const dead = s.status === "Dead";
|
||||
// #162: known from its sidecar with nothing running behind it — a distinct
|
||||
// fragment, since it never exited.
|
||||
const dormant = s.status === "Dormant";
|
||||
const blockers = sessionBlockers(s);
|
||||
const slug = activityColorSlug(s.activity ?? "Idle");
|
||||
const held = activityHeldMs(s.activity_since, now);
|
||||
@@ -127,6 +130,7 @@ function metaLine(s: Session, now: number): string {
|
||||
commandFragment(s.command),
|
||||
blockers ? blockedFragment(blockers) : "",
|
||||
dead ? `<span class="card-flag">exited</span>` : "",
|
||||
dormant ? `<span class="card-flag">dormant</span>` : "",
|
||||
];
|
||||
return `<span class="card-line2" data-activity="${slug}">${fragments.join("")}</span>`;
|
||||
}
|
||||
|
||||
@@ -99,6 +99,20 @@ describe("empty-query ordering", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
// #162: docs/COMMAND-BAR.md ranks by whether you could attach, not by which
|
||||
// way the pane went away.
|
||||
it("sorts dormant sessions after live ones, like dead ones", () => {
|
||||
const dormantRecent = session("dormant", {
|
||||
status: "Dormant",
|
||||
last_focused_at: "2026-06-01T00:00:00Z",
|
||||
});
|
||||
const liveStale = session("live", { last_focused_at: "2020-01-01T00:00:00Z" });
|
||||
expect(orderSessionsForBar([dormantRecent, liveStale]).map((s) => s.id)).toEqual([
|
||||
"live",
|
||||
"dormant",
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats a null last_focused_at as never focused", () => {
|
||||
const nulled = session("nulled", { last_focused_at: null });
|
||||
const stamped = session("stamped", { last_focused_at: "2020-01-01T00:00:00Z" });
|
||||
|
||||
@@ -142,8 +142,10 @@ function createdAt(s: Session): number {
|
||||
* matcher's last tie-break preserves. */
|
||||
export function orderSessionsForBar(sessions: readonly Session[]): Session[] {
|
||||
return [...sessions].sort((a, b) => {
|
||||
const deadA = a.status === "Dead" ? 1 : 0;
|
||||
const deadB = b.status === "Dead" ? 1 : 0;
|
||||
// docs/COMMAND-BAR.md: anything with no live pane — dead or dormant (#162)
|
||||
// — sorts after the sessions you could actually attach to.
|
||||
const deadA = a.status === "Running" ? 0 : 1;
|
||||
const deadB = b.status === "Running" ? 0 : 1;
|
||||
if (deadA !== deadB) return deadA - deadB;
|
||||
|
||||
const focusA = focusedAt(a);
|
||||
@@ -350,7 +352,7 @@ function renderRow(result: BarResult, index: number): HTMLElement {
|
||||
row.dataset.kind = candidate.kind;
|
||||
if (candidate.kind === "session") {
|
||||
row.dataset.sessionId = candidate.session.id;
|
||||
if (candidate.session.status === "Dead") row.classList.add("is-dead");
|
||||
if (candidate.session.status !== "Running") row.classList.add("is-dead");
|
||||
} else if (isActionCandidate(candidate)) {
|
||||
row.dataset.actionId = candidate.action.id;
|
||||
} else if (candidate.kind === "command") {
|
||||
|
||||
+4
-1
@@ -6,7 +6,10 @@ import { escapeHtml } from "./html.js";
|
||||
export type Uuid = string;
|
||||
export type IsoTimestamp = string;
|
||||
|
||||
export type SessionStatus = "Running" | "Dead";
|
||||
/** `Dormant` (#162) is a session the daemon knows only from its sidecar —
|
||||
* recovered after the tmux server died under it, so there is no pane behind it
|
||||
* and nothing to attach to. Listed, never live. */
|
||||
export type SessionStatus = "Running" | "Dead" | "Dormant";
|
||||
|
||||
/** Daemon-inferred activity dimension, orthogonal to `SessionStatus` (NAP-340).
|
||||
* Absent on payloads from daemons that predate the field. `Error` is only set
|
||||
|
||||
Reference in New Issue
Block a user