Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ library plus CoreFoundation/Security.
Run from the repo root:

```bash
zig build test --summary all # unit tests (~3s, 292 at last count)
zig build test --summary all # unit tests (~3s, 293 at last count)
zig build # debug binary → zig-out/bin/lcc
zig build -Doptimize=ReleaseFast # what PATH should be serving
zig build run -- list # run without installing
Expand Down Expand Up @@ -142,6 +142,21 @@ Do not "simplify" `build.zig`'s separate `test_mod`: reusing the executable's mo
improvement and turns every recovered row into an `unknown_session` error. The ids collide
across daemons anyway — `next_id` restarts at 1 — which is also why the state file is named
for Claude Code's session UUID rather than for either the lcc id or the worktree path.
- **A dashboard row has to name a directory that still exists.** `sessions.visible` is that
rule, and *both* ways the rows are read have to go through it — the live snapshot in
`collect` / `snapshotOnce` and `sessions.resolved` — because the daemon never drops a
session from its own list and flushes that whole list once more as it exits, so its file
outlives it naming every worktree it ever ran in. `collect` puts the same predicate on
`app.worktreeChoices`, not inside it: git keeps listing a worktree whose directory was
deleted (`prunable`), and that is exactly the row `lcc list` shows in red and `lcc remove`
needs in order to clean the entry up.
- **A registry row reading `unknown` is bookkeeping, not a session.** `collect` collapses it to
`null` through `liveMatch` before `rowFor` ever sees it. Leave it a match and the worktree's
hook-recovered status is never consulted — which after a daemon dies is every worktree it
touched, so `watch_state` recovers nothing and the column reads `unknown` with an age
measured from the epoch. Passing `rowFor` the recovered status *and* the dead session id
instead is worse than either: `attachable` goes true and enter asks the daemon for a session
nothing holds.
- **A session's hook settings file is per session, not per daemon.** `watch_paths.hooksFor`
names it `hooks-<session id>.json` and `watch_hooks.settingsJson` bakes that id into every
hook command line, so a report says which session it came from. Collapsing them back into
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,16 @@ A session running in another repository still appears, because an agent working
somewhere you are not looking is the one you most need to see. `lcc open xcode`
is unchanged and still opens a worktree in Xcode.

What the list never shows is a directory that is not there. A worktree you
removed drops off the moment its directory does — including one `git worktree
prune` has not caught up with yet, which git still lists — and the sessions that
ran in it go with it rather than staying as rows that open nothing. Nothing
cleans up after them otherwise: the background process never drops a session from
its own list and writes that list once more on the way out, so its file goes on
naming every worktree it ever touched until a new one replaces the file. The one
thing you give up is reaching an agent still running in a directory you deleted —
it has no row any more, and `lcc open --stop-all` is what ends it.

`● waiting` is the one that wants you: the agent is blocked on a permission
prompt or a question. `◐ active` is a turn in flight, `○ idle` is finished.
Those come from Claude Code's own hooks rather than from reading its screen, so
Expand Down Expand Up @@ -183,6 +193,12 @@ normally clears its file and goes back to `no session`, having nothing left to
say. Sessions that died before this shipped left no file behind and stay
`no session` until you start them again.

A row the dead process left in the registry does not outrank that file either. It
can only read `unknown` — the one thing that could have said otherwise is gone —
and what the hooks reported is better than that, so the recovered status wins. It
is still not offered for attach: the session behind that row died with its
process, whatever the registry remembers of it.

Sessions also hold the build of `lcc` they started under. After `zig build` the
sessions still running can be many commits behind the `lcc` you are typing —
older behaviour reached through the same command, and every symptom of it looks
Expand Down
111 changes: 107 additions & 4 deletions src/commands/watch.zig
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,9 @@ fn snapshotOnce(app: app_mod.App, opts: Opts) !void {
const outdated = outdatedDaemon(app, app.gpa, exec.selfModified(app.gpa, app.io));

if (live) |list| {
const rows = try app.gpa.alloc(Row, list.len);
for (list, 0..) |s, i| rows[i] = toRow(s, false);
const present = try onDisk(app.io, app.gpa, list);
const rows = try app.gpa.alloc(Row, present.len);
for (present, 0..) |s, i| rows[i] = toRow(s, false);
return emit(app, opts, rows, true, outdated, now);
}

Expand All @@ -96,6 +97,19 @@ fn outdatedDaemon(app: app_mod.App, arena: std.mem.Allocator, built: ?i64) bool
return sessions.daemonOutdated(sessions.load(arena, app.io, app.environ), built);
}

pub fn onDisk(
io: Io,
arena: std.mem.Allocator,
list: []const sessions.Session,
) ![]const sessions.Session {
var out: std.ArrayList(sessions.Session) = .empty;
for (list) |s| {
if (sessions.visible(io, s, true) == null) continue;
try out.append(arena, s);
}
return out.toOwnedSlice(arena);
}

const outdated_warning = "These sessions are running an older build of lcc than this one.";
const outdated_hint = "They end on their own 30 minutes after the last one finishes. `lcc open --stop-all` is immediate, but ends them now.";

Expand Down Expand Up @@ -368,7 +382,7 @@ fn collect(app: app_mod.App, arena: std.mem.Allocator, now: i64) ![]watch_table.
var live: []const sessions.Session = &.{};
var stale = false;
if (watch_client.snapshot(app) catch null) |list| {
live = list;
live = try onDisk(app.io, arena, list);
} else {
const state = sessions.load(arena, app.io, app.environ);
const resolved = try sessions.resolved(arena, app.io, state, now);
Expand All @@ -385,8 +399,9 @@ fn collect(app: app_mod.App, arena: std.mem.Allocator, now: i64) ![]watch_table.

if (app.repo()) |repo| {
for (try app_mod.worktreeChoices(app, repo)) |choice| {
if (!disk.isDirectory(app.io, choice.entry.path)) continue;
const branch = choice.entry.branch orelse app_mod.shortHead(choice.entry.head);
const match = findSession(live, choice.entry.path);
const match = liveMatch(findSession(live, choice.entry.path));
const recovered: ?watch_state.Resolved = if (match != null) null else recover: {
if (states == null) states = watch_state.load(arena, app.io, app.environ);
break :recover watch_state.statusFor(
Expand Down Expand Up @@ -446,6 +461,11 @@ pub fn rowFor(
};
}

pub fn liveMatch(found: ?sessions.Session) ?sessions.Session {
const session = found orelse return null;
return if (session.parsedStatus() == .unknown) null else session;
}

pub fn findSession(list: []const sessions.Session, worktree: []const u8) ?sessions.Session {
var fallback: ?sessions.Session = null;
for (list) |s| {
Expand Down Expand Up @@ -637,6 +657,89 @@ test "a live session outranks anything left on disk for the same worktree" {
try std.testing.expect(row.attachable());
}

test "a session the daemon is still running in a deleted worktree is dropped too, not just the dead ones" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var arena_state: std.heap.ArenaAllocator = .init(gpa);
defer arena_state.deinit();
const arena = arena_state.allocator();

var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const base = try tmp.dir.realPathFileAlloc(io, ".", arena);
const removed = try std.fs.path.join(arena, &.{ base, "removed" });

const present = try onDisk(io, arena, &.{
.{ .id = "s-here", .worktree = base, .branch = "feature/pe-284", .status = "waiting" },
.{ .id = "s-gone", .worktree = removed, .branch = "feature/pe-283", .status = "active" },
.{ .id = "s-done", .worktree = removed, .branch = "feature/pe-286", .status = "exited" },
});

if (present.len != 1) {
std.debug.print(
"{d} of 3 sessions survived a snapshot with two deleted worktrees: the dashboard " ++
"lists work that has nowhere left to happen, and enter on those rows opens an " ++
"agent in a directory that is not there.\n",
.{present.len},
);
return error.TestExpectedEqual;
}
try std.testing.expectEqualStrings("s-here", present[0].id);
try std.testing.expectEqualStrings("waiting", present[0].status);
}

test "a row left behind by a dead daemon does not outrank what the hooks reported" {
const gpa = std.testing.allocator;
var arena_state: std.heap.ArenaAllocator = .init(gpa);
defer arena_state.deinit();
const arena = arena_state.allocator();

const leftover: sessions.Session = .{
.id = "s-00000006",
.worktree = "/w/pe-290",
.branch = "feature/pe-290",
.issue = "PE-290",
.status = "unknown",
.last_activity_at = 1200,
};

if (liveMatch(leftover) != null) {
std.debug.print(
"a session the daemon left in the registry still counts as a match: it reports " ++
"`unknown` for every worktree that daemon ever touched, and the status the hooks " ++
"wrote to disk is never consulted, which is the whole point of recovering it.\n",
.{},
);
return error.TestUnexpectedResult;
}

const row = rowFor(
arena,
"/w/pe-290",
"feature/pe-290",
liveMatch(leftover),
.{ .status = .waiting, .last_activity_at = 1700 },
false,
);
try std.testing.expectEqual(sessions.Status.waiting, row.status.?);
try std.testing.expectEqual(@as(i64, 1700), row.last_activity_at);

if (row.attachable()) {
std.debug.print(
"the recovered row kept the dead session's id and offers itself for attach: enter " ++
"asks the daemon for a session nothing holds and comes back unknown_session, " ++
"instead of starting the work again.\n",
.{},
);
return error.TestUnexpectedResult;
}

var running = leftover;
running.status = "waiting";
try std.testing.expectEqualStrings("s-00000006", liveMatch(running).?.id);
try std.testing.expect(liveMatch(null) == null);
}

test "a worktree with neither a session nor a report still reads as having none" {
const gpa = std.testing.allocator;
var arena_state: std.heap.ArenaAllocator = .init(gpa);
Expand Down
38 changes: 38 additions & 0 deletions src/disk.zig
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ pub fn realPath(gpa: std.mem.Allocator, io: Io, target: []const u8) []const u8 {
return Io.Dir.cwd().realPathFileAlloc(io, target, gpa) catch target;
}

pub fn isDirectory(io: Io, path: []const u8) bool {
if (path.len == 0) return false;
const info = Io.Dir.cwd().statFile(io, path, .{}) catch return false;
return info.kind == .directory;
}

pub fn removeChild(io: Io, parent: []const u8, path: []const u8) !void {
const dirname = std.fs.path.dirname(path) orelse return error.RefusingToDelete;
const trimmed = std.mem.trimEnd(u8, parent, "/");
Expand All @@ -54,6 +60,38 @@ pub fn abbreviate(gpa: std.mem.Allocator, environ: *const std.process.Environ.Ma
return std.fmt.allocPrint(gpa, "~{s}", .{path[home.len..]}) catch path;
}

test "a worktree is a directory that is there, not a name that used to be one" {
const io = std.testing.io;
const gpa = std.testing.allocator;

var arena_state: std.heap.ArenaAllocator = .init(gpa);
defer arena_state.deinit();
const arena = arena_state.allocator();

var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const base = try tmp.dir.realPathFileAlloc(io, ".", arena);

try std.testing.expect(isDirectory(io, base));

const gone = try std.fs.path.join(arena, &.{ base, "removed" });
try std.testing.expect(!isDirectory(io, gone));

const file = try std.fs.path.join(arena, &.{ base, "a-file" });
try Io.Dir.cwd().writeFile(io, .{ .sub_path = file, .data = "" });
if (isDirectory(io, file)) {
std.debug.print(
"a plain file answered yes: a session whose worktree was replaced by a file of the " ++
"same name keeps its row, and enter on it starts an agent in a directory that " ++
"does not exist.\n",
.{},
);
return error.TestUnexpectedResult;
}

try std.testing.expect(!isDirectory(io, ""));
}

test "isInside distinguishes containment from a shared prefix" {
const gpa = std.testing.allocator;

Expand Down
Loading
Loading