diff --git a/CLAUDE.md b/CLAUDE.md index b117c9a..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 @@ -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);