Compare commits

...
Author SHA1 Message Date
Miguel Palhas ec6345cc32 Merge remote-tracking branch 'origin/main' into reboot/172-worktree-field 2026-09-04 08:48:30 +01:00
Miguel PalhasandClaude Sonnet 5 b05b014e84 fix(maestro): clear worktree from the session clone on natural exit
mark_session_dead cleared blocked_work on the local session clone
written to the sidecar, but not worktree — so a pristine tree the
daemon just removed stayed named in the sidecar, and recovery from
that sidecar advertised a branch and path that no longer exist.

Refs #172.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 08:48:24 +01:00
Miguel Palhas 43fc3a3735 Merge remote-tracking branch 'origin/main' into sarabande
ci / web (push) Successful in 41s
ci / flake (push) Successful in 4m45s
ci / rust (push) Successful in 8m58s
2026-09-04 08:18:02 +01:00
Miguel Palhas ff1ffb377c fix(maestro): leave fast-forward spawns unnamed in the web client
The web N path sent the generated handle as the session name as well as
the worktree branch, so the session was born named and never got its
Haiku name from its first prompt. The TUI already spawns unnamed (#131);
the handle now only seeds the branch.
2026-09-04 08:17:14 +01:00
Miguel Palhas ba74d3515b fix(maestro): keep sidebar accent on the picked session while previewing
ci / rust (push) Successful in 10m9s
ci / web (push) Failing after 7s
ci / flake (push) Successful in 2m56s
A preview moved the active tab's focus, and the sidebar took that as the
focused session, so hovering another row dropped the accent from the row the
operator had actually opened. Report the pre-preview focus instead.
2026-09-04 08:15:35 +01:00
8 changed files with 152 additions and 25 deletions
+1
View File
@@ -1540,6 +1540,7 @@ async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
// The tree is settled (removed); a stale "in the way" would
// describe something the session no longer holds.
session.blocked_work = None;
session.worktree = None;
let mut sessions = inner.sessions.lock().await;
if let Some(record) = sessions.get_mut(&id) {
record.session.worktree = None;
@@ -9,6 +9,7 @@ use std::sync::Arc;
use std::time::Duration;
use common::{init_bare_repo, init_repo, push_all_to_origin, wait_for_async, DaemonFixture};
use maestro_daemon::sidecar::SidecarStore;
use maestro_daemon::SessionError;
use maestro_protocol::CreateSessionRequest;
use maestro_protocol::{CatalogEvent, SessionStatus, WorktreeSpec};
@@ -1304,6 +1305,65 @@ async fn natural_exit_of_a_pristine_worktree_removes_it() {
);
}
/// #172: `mark_session_dead` clears the worktree off the in-memory record
/// when a pristine tree settles, but the local `session` clone it writes to
/// the sidecar and broadcasts must be cleared the same way — otherwise the
/// sidecar keeps naming a tree the daemon just removed, and a recovery from
/// that sidecar advertises a branch and path that no longer exist.
#[tokio::test]
async fn natural_exit_of_a_pristine_worktree_clears_it_from_the_sidecar() {
let fx = DaemonFixture::new();
let repo = TempDir::new().unwrap();
init_repo(repo.path());
let wt_path = repo.path().join("worktrees").join("feat").join("sidecar");
let s = fx
.manager
.create(CreateSessionRequest {
cwd: repo.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: repo.path().to_path_buf(),
branch: "feat/sidecar".into(),
create_branch: true,
}),
})
.await
.expect("create");
// Push everything so the branch has no unpushed commits: fully pristine.
let origin = TempDir::new().unwrap();
init_bare_repo(origin.path());
push_all_to_origin(repo.path(), origin.path());
fx.tmux.tmux.kill_session(s.id).expect("kill tmux directly");
fx.manager.start_liveness_watcher(Duration::from_millis(50));
wait_for_async(Duration::from_secs(3), || async {
fx.manager.status(s.id).await == Some(SessionStatus::Dead)
})
.await;
wait_for_async(Duration::from_secs(3), || async {
fx.manager.is_settled(s.id).await == Some(true)
})
.await;
assert!(
!wt_path.exists(),
"a pristine worktree leaves nothing behind"
);
let sidecar = SidecarStore::new(fx.sidecar_dir())
.read(s.id)
.expect("sidecar read")
.expect("sidecar exists");
assert!(
sidecar.worktree.is_none(),
"the sidecar must not keep naming a worktree the daemon already removed"
);
}
fn run_git_in(dir: &std::path::Path, args: &[&str]) {
let out = Command::new("git")
.arg("-C")
+1
View File
@@ -47,6 +47,7 @@ Web client (`--preview` and friends in `web/src/styles.css`):
| The canvas | A `preview` chip, top right, and a dashed inset ring. |
| A throwaway tab | Dashed border, italic label. |
| The sidebar row being previewed | Filled with the preview hue, over whatever open state the row already had — but the row of the session already showing in the pane keeps its accent fill, since previewing it changes nothing on screen. |
| The sidebar row of the session the operator picked | Keeps its accent fill for the whole preview. A preview never moves the accent; only opening another session does. |
| A hovered sidebar row, and the keyboard cursor on a session | A lighter wash of the same hue, because both are what previews a row. |
Rows say it with fill, not with an edge — see the tier ladder in
+17 -4
View File
@@ -34,12 +34,14 @@ describe("resolveFastForward (#67)", () => {
const res = resolveFastForward(projects, presets, "amber-falcon");
expect(res.project?.path).toBe("/repos/fresh");
expect(res.command).toBe("aider");
expect(res.name).toBe("amber-falcon");
expect(res.handle).toBe("amber-falcon");
const req = buildFastForwardRequest(res)!;
expect(req.cwd).toBe("/repos/fresh");
expect(req.command).toBe("aider");
expect(req.metadata).toEqual({ name: "amber-falcon" });
// No name (#131): the daemon names the session after its first prompt,
// and a name here would collide with the branch handle.
expect(req.metadata).toBeUndefined();
expect(req.worktree).toEqual({
base_repo: "/repos/fresh",
branch: "amber-falcon",
@@ -88,9 +90,9 @@ describe("resolveFastForward (#67)", () => {
expect(buildFastForwardRequest(res)).toBeNull();
});
it("downgrades to a draft when the generated name is missing", () => {
it("downgrades to a draft when the generated handle is missing", () => {
const res = resolveFastForward([project({ last_command: "claude" })], presets);
expect(res.name).toBeUndefined();
expect(res.handle).toBeUndefined();
expect(buildFastForwardRequest(res)).toBeNull();
});
@@ -108,6 +110,17 @@ describe("resolveFastForward (#67)", () => {
});
});
it("seeds the handle as the branch and leaves the name field empty (#131)", () => {
const res = resolveFastForward(
[project({ path: "/repos/p", base_repo: "/repos/p", last_command: "aider" })],
presets,
"amber-falcon",
);
const seed = fastForwardSeed(res);
expect(seed.name).toBe("");
expect(seed.worktreeBranch).toBe("amber-falcon");
});
it("leaves the worktree default alone when not even a project resolved", () => {
// Not `false`: the form defaults the toggle on and a false would stick,
// so the fallback draft would disagree with a normally-opened draft and
+19 -13
View File
@@ -10,19 +10,21 @@ import { sortProjects } from "./projectList.js";
export interface FastForwardResolution {
project?: Project;
command?: string;
name?: string;
/** A generated handle for the worktree branch (#131). Not a session name:
* the fast path names no session. */
handle?: string;
}
/** The defaults: the currently focused session's project when it's still a
* known project, else the most recent one (fresh-first, same ordering the
* picker shows) — so `N` lands where the operator currently is, matching
* what plain `n` seeds from `spawnSimilarProjectSeed`. Its last command
* falls back to the first non-custom preset, and the name is freshly
* generated when the daemon supplied one. */
* falls back to the first non-custom preset, and the branch handle is
* freshly generated when the daemon supplied one. */
export function resolveFastForward(
projects: Project[],
presets: SpawnPreset[],
name?: string,
handle?: string,
focusedCwd?: string,
): FastForwardResolution {
const project =
@@ -30,41 +32,45 @@ export function resolveFastForward(
const lastCommand = project?.last_command?.trim();
const command =
lastCommand || presets.find((p) => p.command !== "")?.command || undefined;
return { project, command, name: name || undefined };
return { project, command, handle: handle || undefined };
}
/** The immediate spawn request — worktree on, new branch named after the
* generated session name — or `null` when any default is missing. */
* generated handle — or `null` when any default is missing.
*
* The request carries no name (#131): the daemon assigns a provisional one
* and replaces it once the session sees its first prompt. A name here would
* also collide with the branch handle's own session. */
export function buildFastForwardRequest(
res: FastForwardResolution,
): CreateSessionRequest | null {
const { project, command, name } = res;
if (!project || !command?.trim() || !name) return null;
const { project, command, handle } = res;
if (!project || !command?.trim() || !handle) return null;
return {
cwd: project.path,
command,
metadata: { name },
worktree: {
base_repo: project.base_repo ?? project.path,
branch: name,
branch: handle,
create_branch: true,
},
};
}
/** Seed for the fallback spawn form: whatever did resolve goes into the
* form; the rest stays with its own defaults. */
* form; the rest stays with its own defaults. The name field stays empty
* (#131) — the handle seeds the branch, not the session name. */
export function fastForwardSeed(res: FastForwardResolution) {
return {
cwd: res.project?.path ?? "",
command: res.command ?? "",
name: res.name ?? "",
name: "",
// Left undefined rather than false when nothing resolved: the form
// defaults the toggle on, and `false` would stick, so the fallback form
// would disagree with both a normally-opened form and the TUI's fast path
// (#67 review).
worktreeEnabled: res.project ? true : undefined,
worktreeBranch:
res.project && res.name ? res.name : undefined,
res.project && res.handle ? res.handle : undefined,
};
}
+4 -4
View File
@@ -356,14 +356,14 @@ async function spawnPresets(): Promise<SpawnPreset[]> {
}
async function requestFastForward(): Promise<void> {
const [discovered, name] = await Promise.all([
const [discovered, handle] = await Promise.all([
fetchProjects().catch(() => [] as Project[]),
fetchNames(1)
.then((batch) => batch.names[0])
.catch(() => undefined),
]);
const presets = await spawnPresets();
const resolved = resolveFastForward(discovered, presets, name, focusedProjectCwd());
const resolved = resolveFastForward(discovered, presets, handle, focusedProjectCwd());
const req = buildFastForwardRequest(resolved);
if (req) {
try {
@@ -390,7 +390,7 @@ async function requestFastForward(): Promise<void> {
// one outside any repo spawns without a worktree, since there is no repo to cut
// a branch from. Anything still unresolved opens the pre-filled form.
async function requestSpawnInDirectory(path: string): Promise<void> {
const [discovered, name] = await Promise.all([
const [discovered, handle] = await Promise.all([
fetchProjects().catch(() => [] as Project[]),
fetchNames(1)
.then((batch) => batch.names[0])
@@ -399,7 +399,7 @@ async function requestSpawnInDirectory(path: string): Promise<void> {
const presets = await spawnPresets();
const known = discovered.find((p) => p.path === path);
const project: Project = known ?? { path, name: path.split("/").filter(Boolean).pop() ?? path };
const resolved = resolveFastForward([project], presets, name, path);
const resolved = resolveFastForward([project], presets, handle, path);
const req = buildFastForwardRequest(resolved);
if (req) {
const inRepo =
+17 -3
View File
@@ -215,6 +215,20 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
return fl?.sessionId ?? null;
}
/** The session the operator actually picked — what `focusedLeafSession()`
* would return with the preview undone. A preview moves the active tab and
* its focus, but it is a view: the accent stays on the row the operator
* chose until they choose another (docs/PREVIEW.md). */
function realFocusedSession(): string | null {
const p = preview;
if (!p) return focusedLeafSession();
const tab = layout.tabs.find((t) => t.id === p.prevActiveTabId) ?? activeTab(layout);
const leafId =
p.prevFocus && p.prevFocus.tabId === tab.id ? p.prevFocus.leafId : tab.focusedLeafId;
const fl = leafId ? findLeaf(tab.root, leafId) : null;
return fl?.sessionId ?? null;
}
/** Update focus styling + pool focus + sidebar highlights without rebuilding
* the DOM (cheap path for click-to-focus). */
function syncFocus(): void {
@@ -233,7 +247,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
if (!preview) pool.setFocus(focusedLeafSession());
sidebar.setHighlights({
open: realOpenSessions(),
focused: focusedLeafSession(),
focused: realFocusedSession(),
});
// The row the preview came from wears the same colour, so the sidebar and
// the pane read as one thing rather than two unrelated highlights.
@@ -608,7 +622,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
{ focus: !preview && !quietRender },
);
if (!preview) pool.setFocus(focused.sessionId ?? null);
sidebar.setHighlights({ open: realOpenSessions(), focused: focused.sessionId ?? null });
sidebar.setHighlights({ open: realOpenSessions(), focused: realFocusedSession() });
sidebar.setPreview(preview?.sessionId ?? null);
}
@@ -817,7 +831,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
openSessions: realOpenSessions,
beginSessionDrag,
render,
focusedSessionId: focusedLeafSession,
focusedSessionId: realFocusedSession,
previewSession,
previewedSessionId: () => preview?.sessionId ?? null,
};
+33 -1
View File
@@ -47,12 +47,20 @@ function fakePool(): Spy {
/** Last value the controller pushed to `SidebarHandle.setPreview`. */
const previewRow: { id: string | null } = { id: null };
/** Last highlights the controller pushed to `SidebarHandle.setHighlights`. */
const rowHighlights: { open: Set<string>; focused: string | null } = {
open: new Set(),
focused: null,
};
function fakeSidebar(): SidebarHandle {
return {
el: document.createElement("aside"),
setActive() {},
setHighlights() {},
setHighlights(h) {
rowHighlights.open = h.open;
rowHighlights.focused = h.focused;
},
setPreview(id: string | null) {
previewRow.id = id;
},
@@ -95,6 +103,8 @@ describe("sidebar preview", () => {
stubFetch();
document.body.replaceChildren();
previewRow.id = null;
rowHighlights.open = new Set();
rowHighlights.focused = null;
});
afterEach(() => {
@@ -134,6 +144,28 @@ describe("sidebar preview", () => {
expect(mv.el.querySelectorAll(".mv-pane.is-preview")).toHaveLength(0);
});
it("leaves the accent on the session the pane actually holds", () => {
const layout = twoTabs();
const spy = fakePool();
const mv = makeMv(layout, spy.pool);
expect(rowHighlights.focused).toBe("s2");
// Walking past other rows never moves the accent off what the operator
// picked: only a real open does (docs/PREVIEW.md).
mv.previewSession("s1");
expect(rowHighlights.focused).toBe("s2");
expect(mv.focusedSessionId()).toBe("s2");
mv.previewSession("s9");
expect(rowHighlights.focused).toBe("s2");
mv.previewSession(null);
expect(rowHighlights.focused).toBe("s2");
mv.openSession("s1");
expect(rowHighlights.focused).toBe("s1");
});
it("shows a session no pane holds in a throwaway tab that leaves no trace", () => {
const layout = twoTabs();
const before = serializeLayout(layout);