From 6471e524d38817fa183046a1e218c8b6bd26fc2f Mon Sep 17 00:00:00 2001 From: Pfriedrix Date: Fri, 7 Aug 2026 18:55:12 +0300 Subject: [PATCH 1/2] feat(watch): let a session's status survive the daemon that ran it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fresh daemon starts with an empty session list and flushes it a second later, so sessions.json loses every row the previous one recorded. The dashboard then joins the worktrees against a registry holding only the session just started, and every other row reads `no session`. Eight sessions became one live row and seven blanks, with nothing left to say which had been mid-turn and which had finished. The sessions themselves cannot come back — the ptys are revoked with the daemon. The status can. Every hook already receives the worktree, the Claude Code session id and the permission mode, and already drops all of it when the socket is not there; it now writes that report to a small file of its own first, which the dashboard reads for any worktree with no live session. Reading Claude Code's transcripts would have covered more, including sessions that died before this shipped, at the cost of the dependency on its internals that the hooks exist to avoid. A recovered row carries no session id, so enter starts the work again with --resume instead of asking for a pty nothing holds. An interrupted turn reads waiting rather than active: it has no process left, and a turn in flight is a claim only a running one can make. --- CLAUDE.md | 19 +- README.md | 14 ++ src/commands/watch.zig | 141 ++++++++++++-- src/main.zig | 1 + src/watch_paths.zig | 57 ++++++ src/watch_state.zig | 431 +++++++++++++++++++++++++++++++++++++++++ src/watch_table.zig | 28 +++ 7 files changed, 679 insertions(+), 12 deletions(-) create mode 100644 src/watch_state.zig diff --git a/CLAUDE.md b/CLAUDE.md index b117c9a..da7c6e6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,7 +80,9 @@ Conventions inside a test: `readMutation`, `unwrap` in `src/linear.zig`) — no requests, no Keychain reads. - Never let a test touch real state under `$HOME`. Build a `std.process.Environ.Map` and set the override the module reads: `LCC_REPOS`, `LCC_USAGE_CACHE`, `LCC_REMOTE_CACHE`, - `LCC_CLAUDE_PROJECTS`, `LCC_CLAUDE_JSON`, `LCC_DERIVED_DATA`. + `LCC_CLAUDE_PROJECTS`, `LCC_CLAUDE_JSON`, `LCC_DERIVED_DATA`, `LCC_SESSIONS`, + `LCC_WATCH_DIR`. The last one moves the socket, the lock, the hook settings and the + recovered-status files together, so it is the one the daemon and `watch_state` tests need. - Failure messages carry what a wrong answer costs, not just the mismatch. `start_plan_test.zig` is the reference for that shape. @@ -116,6 +118,21 @@ Do not "simplify" `build.zig`'s separate `test_mod`: reusing the executable's mo - **`src/keychain.zig` imports five narrow C headers on purpose.** The umbrella `CoreFoundation.h` / `Security.h` do not translate on this SDK. Do not tidy them into one import. +- **A hook event that reports no `permission_mode` must not clear the one already known.** + Only some events carry it — `Notification` does not (see the test in `watch_hooks.zig`). + `watch_session.setPlan` is guarded on `permission_mode.len > 0` for that reason, and + `watch_state.write` merges the previous record's mode in for the same one. Drop either + guard and it still compiles, still passes anything that only replays `PreToolUse`, and + quietly takes a session out of `◈ plan` the first time the agent asks for a permission — + so `plan` only ever survives until the next prompt, which reads as the mode being flaky + rather than as a bug. +- **`watch_state` recovers a status, never a session.** The rows it feeds `collect` keep + `session_id = null` on purpose: that is the only thing making `watch_table.Row.attachable` + return `false`, so enter starts the work again instead of asking the daemon for a pty that + died with the previous one. Filling the id in from the record's `lcc_session` looks like an + 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 session's hook settings file is per session, not per daemon.** `watch_paths.hooksFor` names it `hooks-.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 diff --git a/README.md b/README.md index 11c1d53..0120906 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,20 @@ they do not survive it: if it dies the ptys are revoked and every agent gets a hangup, the same property tmux has. And `lcc remove` does not yet check whether a worktree has a live session in it, so check `lcc open` before removing one. +What *does* survive is the status. Every hook writes what it reported to a small +file of its own, so when the background process is replaced the dashboard still +shows what each worktree was last doing rather than a column of `no session` — +which is what you used to get, because a fresh process rewrites the registry from +the one session it just started and erases the rest. A recovered row reads +exactly like a live one; the AGE column is what tells you the `● waiting` is four +hours old. Enter on it starts the work again with `--resume` instead of +attaching, since there is no longer a session to attach to. An agent interrupted +mid-turn reads `● waiting` rather than `◐ active`: it has no process left, and a +turn in flight is a claim only a running one can make. A session you quit +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. + 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 diff --git a/src/commands/watch.zig b/src/commands/watch.zig index 51e7a8c..4f4d4d6 100644 --- a/src/commands/watch.zig +++ b/src/commands/watch.zig @@ -9,6 +9,8 @@ const watch_client = @import("../watch_client.zig"); const term = @import("../term.zig"); const watch_attach = @import("../watch_attach.zig"); const watch_hooks = @import("../watch_hooks.zig"); +const watch_state = @import("../watch_state.zig"); +const disk = @import("../disk.zig"); const claude = @import("../claude.zig"); const claude_projects = @import("../claude_projects.zig"); const linear = @import("../linear.zig"); @@ -379,21 +381,20 @@ fn collect(app: app_mod.App, arena: std.mem.Allocator, now: i64) ![]watch_table. live = carried; } + var states: ?[]const watch_state.Record = null; + if (app.repo()) |repo| { for (try app_mod.worktreeChoices(app, repo)) |choice| { const branch = choice.entry.branch orelse app_mod.shortHead(choice.entry.head); const match = findSession(live, choice.entry.path); - try rows.append(arena, .{ - .key = choice.entry.path, - .session_id = if (match) |m| m.id else null, - .status = if (match) |m| m.parsedStatus() else null, - .issue = if (match) |m| m.issue else issueOf(arena, branch), - .branch = branch, - .worktree = choice.entry.path, - .last_activity_at = if (match) |m| m.last_activity_at else 0, - .exit_code = if (match) |m| m.exit_code else null, - .stale = stale and match != null, - }); + 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( + states.?, + disk.realPath(arena, app.io, choice.entry.path), + ); + }; + try rows.append(arena, rowFor(arena, choice.entry.path, branch, match, recovered, stale)); } } else |_| {} @@ -419,6 +420,32 @@ fn collect(app: app_mod.App, arena: std.mem.Allocator, now: i64) ![]watch_table. return rows.toOwnedSlice(arena); } +pub fn rowFor( + arena: std.mem.Allocator, + path: []const u8, + branch: []const u8, + match: ?sessions.Session, + recovered: ?watch_state.Resolved, + stale: bool, +) watch_table.Row { + return .{ + .key = path, + .session_id = if (match) |m| m.id else null, + .status = if (match) |m| m.parsedStatus() else if (recovered) |r| r.status else null, + .issue = if (match) |m| m.issue else issueOf(arena, branch), + .branch = branch, + .worktree = path, + .last_activity_at = if (match) |m| + m.last_activity_at + else if (recovered) |r| + r.last_activity_at + else + 0, + .exit_code = if (match) |m| m.exit_code else null, + .stale = stale and match != null, + }; +} + pub fn findSession(list: []const sessions.Session, worktree: []const u8) ?sessions.Session { var fallback: ?sessions.Session = null; for (list) |s| { @@ -498,6 +525,8 @@ pub fn hook(app: app_mod.App, opts: HookOpts) !void { const payload = watch_hooks.parsePayload(app.gpa, raw) orelse return; if (payload.cwd.len == 0) return; + recordState(app, opts, payload, event); + watch_client.report( app, opts.socket, @@ -509,6 +538,27 @@ pub fn hook(app: app_mod.App, opts: HookOpts) !void { ); } +fn recordState( + app: app_mod.App, + opts: HookOpts, + payload: watch_hooks.Payload, + event: []const u8, +) void { + const parsed = watch_hooks.Event.parse(event) orelse return; + if (parsed == .ended) { + watch_state.clear(app.gpa, app.io, app.environ, payload.session_id); + return; + } + watch_state.write(app.gpa, app.io, app.environ, .{ + .event = event, + .cwd = payload.cwd, + .claude_session = payload.session_id, + .lcc_session = opts.session orelse "", + .permission_mode = payload.permission_mode, + .at = app_mod.nowSeconds(app.io), + }); +} + test "the --json keys name sessions, never the process behind them" { const gpa = std.testing.allocator; @@ -532,6 +582,75 @@ test "an empty snapshot still carries both flags, rather than dropping them" { try std.testing.expect(std.mem.indexOf(u8, body, "\"outdated_build\": false") != null); } +test "a worktree the daemon lost still wears the status its hooks last reported" { + const gpa = std.testing.allocator; + var arena_state: std.heap.ArenaAllocator = .init(gpa); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const row = rowFor( + arena, + "/w/pe-290", + "feature/pe-290-relocate-chat-thread-state", + null, + .{ .status = .waiting, .last_activity_at = 1700 }, + false, + ); + + if (row.status == null) { + std.debug.print( + "the row came back with no status even though a hook report for that worktree " ++ + "was on disk: every worktree the daemon outlived reads `no session`, which " ++ + "is the whole failure this recovers from.\n", + .{}, + ); + return error.TestExpectedEqual; + } + try std.testing.expectEqual(sessions.Status.waiting, row.status.?); + try std.testing.expectEqual(@as(i64, 1700), row.last_activity_at); + + try std.testing.expect(row.session_id == null); + try std.testing.expect(!row.attachable()); + + try std.testing.expectEqualStrings("PE-290", row.issue.?); +} + +test "a live session outranks anything left on disk for the same worktree" { + const gpa = std.testing.allocator; + var arena_state: std.heap.ArenaAllocator = .init(gpa); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const live: sessions.Session = .{ + .id = "s-00000004", + .worktree = "/w/pe-290", + .branch = "feature/pe-290", + .issue = "PE-290", + .status = "active", + .last_activity_at = 9000, + }; + + const row = rowFor(arena, "/w/pe-290", "feature/pe-290", live, null, false); + try std.testing.expectEqual(sessions.Status.active, row.status.?); + try std.testing.expectEqualStrings("s-00000004", row.session_id.?); + try std.testing.expectEqual(@as(i64, 9000), row.last_activity_at); + try std.testing.expect(row.attachable()); +} + +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); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const row = rowFor(arena, "/w/quiet", "feature/pe-9-unrelated", null, null, true); + try std.testing.expect(row.status == null); + try std.testing.expect(row.session_id == null); + try std.testing.expectEqual(@as(i64, 0), row.last_activity_at); + + try std.testing.expect(!row.stale); +} + test "a worktree row shows the session that is alive, not the first one recorded" { const dead: sessions.Session = .{ .id = "s-00000005", diff --git a/src/main.zig b/src/main.zig index 93b6422..daffbab 100644 --- a/src/main.zig +++ b/src/main.zig @@ -575,6 +575,7 @@ test { _ = @import("watch_hooks.zig"); _ = @import("watch_paths.zig"); _ = @import("watch_session.zig"); + _ = @import("watch_state.zig"); _ = @import("watch_status.zig"); _ = @import("watch_table.zig"); _ = @import("wire.zig"); diff --git a/src/watch_paths.zig b/src/watch_paths.zig index 95ceff5..a1d0c23 100644 --- a/src/watch_paths.zig +++ b/src/watch_paths.zig @@ -41,6 +41,26 @@ pub fn hooksFor( return std.fs.path.join(gpa, &.{ base, name }); } +pub const state_prefix = "state-"; +pub const state_suffix = ".json"; + +pub fn stateFor( + gpa: std.mem.Allocator, + environ: *const std.process.Environ.Map, + claude_session_id: []const u8, +) ![]const u8 { + const base = try dir(gpa, environ); + const name = try std.fmt.allocPrint(gpa, state_prefix ++ "{s}" ++ state_suffix, .{claude_session_id}); + return std.fs.path.join(gpa, &.{ base, name }); +} + +pub fn stateName(name: []const u8) ?[]const u8 { + if (!std.mem.startsWith(u8, name, state_prefix)) return null; + if (!std.mem.endsWith(u8, name, state_suffix)) return null; + const id = name[state_prefix.len .. name.len - state_suffix.len]; + return if (id.len == 0) null else id; +} + pub fn logFile(gpa: std.mem.Allocator, environ: *const std.process.Environ.Map) ![]const u8 { if (environ.get("LCC_WATCH_DIR")) |raw| { const override = std.mem.trim(u8, raw, " \t"); @@ -93,6 +113,43 @@ test "each session gets a settings file of its own, named for it" { )); } +test "a session's recovered state is filed under Claude Code's id, not the one lcc reissues" { + const gpa = std.testing.allocator; + var arena_state: std.heap.ArenaAllocator = .init(gpa); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var environ: std.process.Environ.Map = .init(arena); + try environ.put("LCC_WATCH_DIR", "/tmp/lcc-test"); + + const uuid = "529ae132-1fc2-4cbd-a909-585f29f46f62"; + try std.testing.expectEqualStrings( + "/tmp/lcc-test/state-" ++ uuid ++ ".json", + try stateFor(arena, &environ, uuid), + ); + + const first = try stateFor(arena, &environ, "s-00000001"); + const hooks = try hooksFor(arena, &environ, "s-00000001"); + if (std.mem.eql(u8, first, hooks)) { + std.debug.print( + "the state file and the hook settings collide on one name: writing a status " ++ + "report would overwrite the settings the session was launched with.\n", + .{}, + ); + return error.TestUnexpectedResult; + } +} + +test "a state file names the session it came from, and nothing else in the directory does" { + try std.testing.expectEqualStrings("abc-123", stateName("state-abc-123.json").?); + + try std.testing.expect(stateName("state-.json") == null); + try std.testing.expect(stateName("hooks-s-00000001.json") == null); + try std.testing.expect(stateName("sessions.json") == null); + try std.testing.expect(stateName("daemon.sock") == null); + try std.testing.expect(stateName("state-abc-123.json.tmp") == null); +} + test "an empty override is not an override" { const gpa = std.testing.allocator; var arena_state: std.heap.ArenaAllocator = .init(gpa); diff --git a/src/watch_state.zig b/src/watch_state.zig new file mode 100644 index 0000000..ffa9731 --- /dev/null +++ b/src/watch_state.zig @@ -0,0 +1,431 @@ +const std = @import("std"); +const Io = std.Io; +const disk = @import("disk.zig"); +const sessions = @import("sessions.zig"); +const watch_hooks = @import("watch_hooks.zig"); +const watch_paths = @import("watch_paths.zig"); +const watch_status = @import("watch_status.zig"); + +pub const version: u32 = 1; + +const record_limit = 64 * 1024; + +pub const Record = struct { + version: u32 = 0, + event: []const u8 = "", + cwd: []const u8 = "", + claude_session: []const u8 = "", + lcc_session: []const u8 = "", + permission_mode: []const u8 = "", + at: i64 = 0, +}; + +pub fn recover(record: Record) ?sessions.Status { + const event = watch_hooks.Event.parse(record.event) orelse return null; + const status: sessions.Status = switch (event) { + .ended => return null, + .active, .waiting => .waiting, + .idle => .idle, + }; + return watch_status.present(status, watch_hooks.isPlan(record.permission_mode)); +} + +pub fn newestFor(records: []const Record, cwd: []const u8) ?Record { + var best: ?Record = null; + for (records) |record| { + if (!std.mem.eql(u8, record.cwd, cwd)) continue; + if (best) |current| { + if (record.at <= current.at) continue; + } + best = record; + } + return best; +} + +pub fn statusFor(records: []const Record, cwd: []const u8) ?Resolved { + const record = newestFor(records, cwd) orelse return null; + const status = recover(record) orelse return null; + return .{ .status = status, .last_activity_at = record.at }; +} + +pub const Resolved = struct { + status: sessions.Status, + last_activity_at: i64, +}; + +pub fn write( + gpa: std.mem.Allocator, + io: Io, + environ: *const std.process.Environ.Map, + record: Record, +) void { + if (record.claude_session.len == 0 or record.cwd.len == 0) return; + const file_path = watch_paths.stateFor(gpa, environ, record.claude_session) catch return; + + var merged = record; + merged.version = version; + if (merged.permission_mode.len == 0) { + if (readAt(gpa, io, file_path)) |previous| merged.permission_mode = previous.permission_mode; + } + + const body = std.json.Stringify.valueAlloc(gpa, merged, .{ .whitespace = .indent_2 }) catch return; + defer gpa.free(body); + + const cwd = Io.Dir.cwd(); + if (std.fs.path.dirname(file_path)) |parent| cwd.createDirPath(io, parent) catch return; + + const tmp_path = std.fmt.allocPrint(gpa, "{s}.tmp", .{file_path}) catch return; + cwd.writeFile(io, .{ .sub_path = tmp_path, .data = body }) catch return; + Io.Dir.renameAbsolute(tmp_path, file_path, io) catch { + cwd.deleteFile(io, tmp_path) catch {}; + }; +} + +pub fn clear( + gpa: std.mem.Allocator, + io: Io, + environ: *const std.process.Environ.Map, + claude_session: []const u8, +) void { + if (claude_session.len == 0) return; + const file_path = watch_paths.stateFor(gpa, environ, claude_session) catch return; + Io.Dir.cwd().deleteFile(io, file_path) catch {}; +} + +fn readAt(gpa: std.mem.Allocator, io: Io, file_path: []const u8) ?Record { + const raw = Io.Dir.cwd().readFileAlloc(io, file_path, gpa, .limited(record_limit)) catch return null; + const parsed = std.json.parseFromSliceLeaky(Record, gpa, raw, .{ + .ignore_unknown_fields = true, + .allocate = .alloc_always, + }) catch return null; + if (parsed.version != version) return null; + return parsed; +} + +pub fn load( + gpa: std.mem.Allocator, + io: Io, + environ: *const std.process.Environ.Map, +) []const Record { + const base = watch_paths.dir(gpa, environ) catch return &.{}; + var dir = Io.Dir.cwd().openDir(io, base, .{ .iterate = true }) catch return &.{}; + defer dir.close(io); + + var out: std.ArrayList(Record) = .empty; + var it = dir.iterate(); + while (it.next(io) catch null) |dirent| { + if (dirent.kind != .file) continue; + if (watch_paths.stateName(dirent.name) == null) continue; + + const file_path = std.fs.path.join(gpa, &.{ base, dirent.name }) catch continue; + var record = readAt(gpa, io, file_path) orelse continue; + if (record.cwd.len == 0) continue; + if (!worktreeExists(io, record.cwd)) continue; + + record.cwd = disk.realPath(gpa, io, record.cwd); + out.append(gpa, record) catch continue; + } + return out.toOwnedSlice(gpa) catch &.{}; +} + +fn worktreeExists(io: Io, worktree: []const u8) bool { + const info = Io.Dir.cwd().statFile(io, worktree, .{}) catch return false; + return info.kind == .directory; +} + +const testing = std.testing; + +fn testEnviron(arena: std.mem.Allocator, base: []const u8) !std.process.Environ.Map { + var environ: std.process.Environ.Map = .init(arena); + try environ.put("LCC_WATCH_DIR", base); + return environ; +} + +test "a turn cut off by a dead daemon asks for a person, rather than claiming it is still running" { + const interrupted: Record = .{ .version = version, .event = "active", .cwd = "/w", .at = 1000 }; + const got = recover(interrupted).?; + if (got != .waiting) { + std.debug.print( + "an interrupted turn reads {s}: the dashboard paints a green turn-in-flight for an " ++ + "agent that no longer has a process, so the one worktree that actually needs " ++ + "opening looks like the busy one to leave alone.\n", + .{@tagName(got)}, + ); + return error.TestExpectedEqual; + } + + try testing.expect(recover(interrupted).? != .active); +} + +test "a session that ended cleanly leaves no row to recover" { + const ended: Record = .{ .version = version, .event = "ended", .cwd = "/w", .at = 1000 }; + try testing.expect(recover(ended) == null); + + const nonsense: Record = .{ .version = version, .event = "thinking_very_hard", .cwd = "/w", .at = 1000 }; + try testing.expect(recover(nonsense) == null); + + const silent: Record = .{ .version = version, .event = "", .cwd = "/w", .at = 1000 }; + try testing.expect(recover(silent) == null); +} + +test "a finished turn and a blocked one stay the two different things they were" { + const idle: Record = .{ .version = version, .event = "idle", .cwd = "/w", .at = 1000 }; + try testing.expectEqual(sessions.Status.idle, recover(idle).?); + + const waiting: Record = .{ .version = version, .event = "waiting", .cwd = "/w", .at = 1000 }; + try testing.expectEqual(sessions.Status.waiting, recover(waiting).?); +} + +test "a recovered status is never one only a live session can be in" { + for ([_][]const u8{ "waiting", "active", "idle" }) |event| { + const status = recover(.{ .version = version, .event = event, .cwd = "/w", .at = 1000 }).?; + try testing.expect(status != .active); + try testing.expect(status != .starting); + try testing.expect(status != .exited); + try testing.expect(status != .orphan); + try testing.expect(status != .unknown); + } +} + +test "being blocked outranks plan mode, here as everywhere else" { + const planning: Record = .{ + .version = version, + .event = "idle", + .cwd = "/w", + .permission_mode = "plan", + .at = 1000, + }; + try testing.expectEqual(sessions.Status.plan, recover(planning).?); + + var blocked = planning; + blocked.event = "waiting"; + try testing.expectEqual(sessions.Status.waiting, recover(blocked).?); + + var working = planning; + working.event = "active"; + try testing.expectEqual(sessions.Status.waiting, recover(working).?); +} + +test "the newest record wins when one worktree held two sessions" { + const older: Record = .{ .version = version, .event = "idle", .cwd = "/w", .claude_session = "a", .at = 1000 }; + const newer: Record = .{ .version = version, .event = "waiting", .cwd = "/w", .claude_session = "b", .at = 2000 }; + + try testing.expectEqualStrings("b", newestFor(&.{ older, newer }, "/w").?.claude_session); + try testing.expectEqualStrings("b", newestFor(&.{ newer, older }, "/w").?.claude_session); + try testing.expect(newestFor(&.{ older, newer }, "/elsewhere") == null); + + const resolved = statusFor(&.{ older, newer }, "/w").?; + try testing.expectEqual(sessions.Status.waiting, resolved.status); + try testing.expectEqual(@as(i64, 2000), resolved.last_activity_at); +} + +test "a worktree whose only record is a finished session reports nothing, not a stale row" { + const ended: Record = .{ .version = version, .event = "ended", .cwd = "/w", .at = 1000 }; + try testing.expect(statusFor(&.{ended}, "/w") == null); +} + +test "plan mode survives the events that report no mode at all" { + const gpa = testing.allocator; + const io = testing.io; + var arena_state: std.heap.ArenaAllocator = .init(gpa); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + const base = try tmp.dir.realPathFileAlloc(io, ".", arena); + var environ = try testEnviron(arena, base); + + write(arena, io, &environ, .{ + .event = "active", + .cwd = base, + .claude_session = "uuid-1", + .permission_mode = "plan", + .at = 1000, + }); + + write(arena, io, &environ, .{ + .event = "waiting", + .cwd = base, + .claude_session = "uuid-1", + .at = 1010, + }); + + const records = load(arena, io, &environ); + try testing.expectEqual(@as(usize, 1), records.len); + if (!watch_hooks.isPlan(records[0].permission_mode)) { + std.debug.print( + "the mode came back {s} after a Notification that carried none: every permission " ++ + "prompt would drop the session out of plan mode on the dashboard, so `plan` " ++ + "only ever survives until the agent next asks for something.\n", + .{records[0].permission_mode}, + ); + return error.TestExpectedEqual; + } + try testing.expectEqualStrings("waiting", records[0].event); + try testing.expectEqual(@as(i64, 1010), records[0].at); +} + +test "a mode that is reported replaces the one remembered, rather than sticking" { + const gpa = testing.allocator; + const io = testing.io; + var arena_state: std.heap.ArenaAllocator = .init(gpa); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + const base = try tmp.dir.realPathFileAlloc(io, ".", arena); + var environ = try testEnviron(arena, base); + + write(arena, io, &environ, .{ + .event = "active", + .cwd = base, + .claude_session = "uuid-1", + .permission_mode = "plan", + .at = 1000, + }); + write(arena, io, &environ, .{ + .event = "active", + .cwd = base, + .claude_session = "uuid-1", + .permission_mode = "default", + .at = 1010, + }); + + const records = load(arena, io, &environ); + try testing.expectEqual(@as(usize, 1), records.len); + try testing.expect(!watch_hooks.isPlan(records[0].permission_mode)); +} + +test "a state file survives a round trip, and ending the session removes it" { + const gpa = testing.allocator; + const io = testing.io; + var arena_state: std.heap.ArenaAllocator = .init(gpa); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + const base = try tmp.dir.realPathFileAlloc(io, ".", arena); + var environ = try testEnviron(arena, base); + + try testing.expectEqual(@as(usize, 0), load(arena, io, &environ).len); + + write(arena, io, &environ, .{ + .event = "waiting", + .cwd = base, + .claude_session = "uuid-1", + .lcc_session = "s-00000003", + .at = 1000, + }); + + const records = load(arena, io, &environ); + try testing.expectEqual(@as(usize, 1), records.len); + try testing.expectEqualStrings("uuid-1", records[0].claude_session); + try testing.expectEqualStrings("s-00000003", records[0].lcc_session); + try testing.expectEqualStrings("waiting", records[0].event); + try testing.expectEqual(version, records[0].version); + + clear(arena, io, &environ, "uuid-1"); + try testing.expectEqual(@as(usize, 0), load(arena, io, &environ).len); +} + +test "a record with no session to name it is never written" { + const gpa = testing.allocator; + const io = testing.io; + var arena_state: std.heap.ArenaAllocator = .init(gpa); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + const base = try tmp.dir.realPathFileAlloc(io, ".", arena); + var environ = try testEnviron(arena, base); + + write(arena, io, &environ, .{ .event = "waiting", .cwd = base, .claude_session = "", .at = 1000 }); + write(arena, io, &environ, .{ .event = "waiting", .cwd = "", .claude_session = "uuid-1", .at = 1000 }); + + try testing.expectEqual(@as(usize, 0), load(arena, io, &environ).len); +} + +test "a record from a newer lcc is ignored rather than half-read" { + const gpa = testing.allocator; + const io = testing.io; + var arena_state: std.heap.ArenaAllocator = .init(gpa); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + const base = try tmp.dir.realPathFileAlloc(io, ".", arena); + var environ = try testEnviron(arena, base); + + const future = try watch_paths.stateFor(arena, &environ, "uuid-future"); + try Io.Dir.cwd().writeFile(io, .{ + .sub_path = future, + .data = try std.fmt.allocPrint( + arena, + "{{\"version\":99,\"event\":\"waiting\",\"cwd\":\"{s}\",\"at\":1000}}", + .{base}, + ), + }); + + const broken = try watch_paths.stateFor(arena, &environ, "uuid-broken"); + try Io.Dir.cwd().writeFile(io, .{ .sub_path = broken, .data = "{not json" }); + + try testing.expectEqual(@as(usize, 0), load(arena, io, &environ).len); +} + +test "a record whose worktree is gone is dropped, not shown against a directory that no longer exists" { + const gpa = testing.allocator; + const io = testing.io; + var arena_state: std.heap.ArenaAllocator = .init(gpa); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + const base = try tmp.dir.realPathFileAlloc(io, ".", arena); + var environ = try testEnviron(arena, base); + + write(arena, io, &environ, .{ + .event = "waiting", + .cwd = try std.fs.path.join(arena, &.{ base, "removed" }), + .claude_session = "uuid-gone", + .at = 1000, + }); + write(arena, io, &environ, .{ + .event = "waiting", + .cwd = base, + .claude_session = "uuid-here", + .at = 1000, + }); + + const records = load(arena, io, &environ); + try testing.expectEqual(@as(usize, 1), records.len); + try testing.expectEqualStrings("uuid-here", records[0].claude_session); +} + +test "nothing in the watch directory but a state file is read as one" { + const gpa = testing.allocator; + const io = testing.io; + var arena_state: std.heap.ArenaAllocator = .init(gpa); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + const base = try tmp.dir.realPathFileAlloc(io, ".", arena); + var environ = try testEnviron(arena, base); + + const cwd = Io.Dir.cwd(); + for ([_][]const u8{ "sessions.json", "hooks-s-00000001.json", "repos.json", "config.json" }) |name| { + try cwd.writeFile(io, .{ + .sub_path = try std.fs.path.join(arena, &.{ base, name }), + .data = "{\"version\":1,\"event\":\"waiting\",\"cwd\":\"/\",\"at\":1}", + }); + } + + try testing.expectEqual(@as(usize, 0), load(arena, io, &environ).len); +} diff --git a/src/watch_table.zig b/src/watch_table.zig index 5d9f00a..089893b 100644 --- a/src/watch_table.zig +++ b/src/watch_table.zig @@ -368,6 +368,34 @@ test "a row is attachable only when something is actually behind it" { try testing.expect(!row.attachable()); } +test "a status recovered from disk is read, but never offered as a session to attach to" { + for ([_]sessions.Status{ .waiting, .idle, .plan }) |status| { + const row: Row = .{ + .key = "/w", + .session_id = null, + .status = status, + .issue = "PE-290", + .branch = "feature/pe-290", + .worktree = "/w", + .last_activity_at = 900, + .exit_code = null, + .stale = false, + }; + + try testing.expectEqualStrings(@tagName(status), statusText(row.status)); + + if (row.attachable()) { + std.debug.print( + "a {s} row rebuilt from a hook report offers itself for attach, but the daemon " ++ + "that owned that pty is gone: enter would ask for a session id nothing " ++ + "holds and come back unknown_session instead of starting the work again.\n", + .{@tagName(status)}, + ); + return error.TestUnexpectedResult; + } + } +} + test "a planning row says plan, not active" { var buf: [4096]u8 = undefined; var w: Io.Writer = .fixed(&buf); From f4c10680df0d01686ab48ac29a5965821321ff3f Mon Sep 17 00:00:00 2001 From: Pfriedrix Date: Fri, 7 Aug 2026 18:57:31 +0300 Subject: [PATCH 2/2] docs: correct the test count this change moved The sanity check CLAUDE.md prescribes is that the pass count moved when a source file is added; it is worth less when the number to compare against is two hundred tests stale. --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index da7c6e6..10e2bcc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,7 @@ library plus CoreFoundation/Security. Run from the repo root: ```bash -zig build test --summary all # unit tests (~3s, 128 at last count) +zig build test --summary all # unit tests (~3s, 289 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