Skip to content
Closed
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
1 change: 1 addition & 0 deletions TUI/catalog.zig
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ pub const Item = struct {

pub const items = [_]Item{
.{ .name = "/new", .desc = "Start a fresh session", .aliases = &.{"/clear"} },
.{ .name = "/resume", .desc = "Resume or branch a saved session" },
.{ .name = "/home", .desc = "Return to the welcome screen", .aliases = &.{"/welcome"} },
.{ .name = "/compact", .desc = "Engine-compact model-visible history" },
.{ .name = "/context", .desc = "Show context-window use" },
Expand Down
18 changes: 17 additions & 1 deletion TUI/dispatch.zig
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const bgop = @import("bgop.zig");
const catalog = @import("catalog.zig");
const engine = @import("engine.zig");
const peer_cmd = @import("peer_cmd.zig");
const resume_mod = @import("resume.zig");
const meters = @import("meters.zig");
const theme_mod = @import("theme.zig");
const turn = @import("turn.zig");
Expand Down Expand Up @@ -58,7 +59,7 @@ pub fn runCommand(self: *Model, line: []const u8) Effect {
// #521: history-destroying commands must not run under a live job — the
// steer guard in promptKey only covers plain text, and the slash menu,
// palette, and steer drain all land here.
const destroys = std.mem.eql(u8, canon, "/new") or std.mem.eql(u8, canon, "/compact") or std.mem.eql(u8, canon, "/rewind");
const destroys = std.mem.eql(u8, canon, "/new") or std.mem.eql(u8, canon, "/compact") or std.mem.eql(u8, canon, "/rewind") or std.mem.eql(u8, canon, "/resume");
if (self.pending != null and destroys) {
self.push(.system, "a turn is still running — press Esc to cancel it first") catch {};
return .stay;
Expand All @@ -76,6 +77,8 @@ pub fn runCommand(self: *Model, line: []const u8) Effect {
_ = self.newSession(); // the `destroys` guard above already refused a live call
self.push(.system, "started a new conversation") catch {};
self.screen = .welcome;
} else if (std.mem.eql(u8, canon, "/resume")) {
resume_mod.run(self, arg);
} else if (std.mem.eql(u8, canon, "/home")) {
self.screen = .welcome;
self.focus = .prompt;
Expand Down Expand Up @@ -142,17 +145,21 @@ pub fn runCommand(self: *Model, line: []const u8) Effect {
self.pushFmt(.system, "fast: {s}", .{onOff(self.fast)}) catch {};
} else if (std.mem.eql(u8, canon, "/ultracode")) {
self.ultracode = !self.ultracode;
publishState(self);
self.pushFmt(.system, "ultracode: {s}", .{onOff(self.ultracode)}) catch {};
} else if (std.mem.eql(u8, canon, "/strict")) {
self.strict = !self.strict;
publishState(self);
self.pushFmt(.system, "strict: {s}", .{onOff(self.strict)}) catch {};
} else if (std.mem.eql(u8, canon, "/goal")) {
if (self.goal) |g| self.alloc.free(g);
self.goal = if (arg.len > 0) (self.alloc.dupe(u8, arg) catch null) else null;
publishState(self);
if (self.goal) |g| self.pushFmt(.system, "goal set: {s}", .{g}) catch {} else self.push(.system, "goal cleared") catch {};
} else if (std.mem.eql(u8, canon, "/rename")) {
if (self.session_name) |s| self.alloc.free(s);
self.session_name = if (arg.len > 0) (self.alloc.dupe(u8, arg) catch null) else null;
publishState(self);
self.pushFmt(.system, "session: {s}", .{self.session_name orelse "untitled"}) catch {};
} else if (std.mem.eql(u8, canon, "/session-info")) {
meters.sessionInfo(self);
Expand Down Expand Up @@ -208,6 +215,15 @@ pub fn runCommand(self: *Model, line: []const u8) Effect {
return if (self.quit_requested) .quit else .stay;
}

fn publishState(self: *Model) void {
if (engine.g_state_fn) |f| f(engine.g_turn_ctx, .{
.session_name = self.session_name orelse "",
.goal = self.goal orelse "",
.strict = self.strict,
.ultracode = self.ultracode,
});
}

/// `!cmd` — run a shell line locally (grok-style bash mode) on a background
/// thread, so a slow command no longer freezes the frame for its whole 20s
/// cap (#533). Output stays out of the model history: EntryKind.system never
Expand Down
28 changes: 28 additions & 0 deletions TUI/engine.zig
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,24 @@ pub const CompactFn = *const fn (turn_ctx: ?*anyopaque, gpa: std.mem.Allocator,
pub const HistoryOp = enum { reset, rewind };
pub const HistoryFn = *const fn (turn_ctx: ?*anyopaque, op: HistoryOp) void;

pub const SessionState = struct {
session_name: []const u8 = "",
goal: []const u8 = "",
strict: bool = false,
ultracode: bool = false,
};
pub const StateFn = *const fn (turn_ctx: ?*anyopaque, state: SessionState) void;

pub const ResumeOut = struct {
turns: []Turn = &.{},
session_name: []const u8 = "",
goal: []const u8 = "",
strict: bool = false,
ultracode: bool = false,
note: []const u8 = "",
};
pub const ResumeFn = *const fn (turn_ctx: ?*anyopaque, gpa: std.mem.Allocator, spec: []const u8, out: *ResumeOut) bool;

pub const Job = struct {
thread: std.Thread = undefined,
threaded: bool = true,
Expand Down Expand Up @@ -238,6 +256,11 @@ pub const RunOpts = struct {
cancel_fn: ?CancelFn = null,
model_name: []const u8 = "",
model_provider: []const u8 = "",
initial_history: []const Turn = &.{},
session_name: []const u8 = "",
initial_goal: []const u8 = "",
initial_strict: bool = false,
initial_ultracode: bool = false,
/// The model catalog with its provider column (see ModelEntry).
model_entries: []const ModelEntry = &.{},
cwd: []const u8 = ".",
Expand All @@ -249,6 +272,9 @@ pub const RunOpts = struct {
copy_fn: ?CopyFn = null,
compact_fn: ?CompactFn = null,
history_fn: ?HistoryFn = null,
resume_fn: ?ResumeFn = null,
state_fn: ?StateFn = null,
emergency_fn: ?*const fn (turn_ctx: ?*anyopaque) void = null,
idle_wake_fn: ?IdleWakeFn = null,
peer_fn: ?PeerFn = null,
};
Expand All @@ -264,6 +290,8 @@ pub var g_files_fn: ?FilesFn = null;
pub var g_copy_fn: ?CopyFn = null;
pub var g_compact_fn: ?CompactFn = null;
pub var g_history_fn: ?HistoryFn = null;
pub var g_resume_fn: ?ResumeFn = null;
pub var g_state_fn: ?StateFn = null;

/// Tell the engine the transcript was cut. Silent when nothing is wired
/// (offline TUI, unit tests).
Expand Down
70 changes: 70 additions & 0 deletions TUI/resume.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
//! Fullscreen `/resume SOURCE [--branch DEST]` projection.

const std = @import("std");
const app = @import("app.zig");
const engine = @import("engine.zig");

pub fn run(self: *app.Model, spec: []const u8) void {
if (spec.len == 0) {
self.push(.system, "usage: /resume SOURCE [--branch DEST]") catch {};
return;
}
const callback = engine.g_resume_fn orelse {
self.push(.system, "resume isn't available (offline)") catch {};
return;
};
var out: engine.ResumeOut = .{};
if (!callback(engine.g_turn_ctx, self.alloc, spec, &out)) {
if (out.note.len > 0) {
self.push(.err, out.note) catch {};
self.alloc.free(out.note);
} else self.push(.err, "resume failed") catch {};
return;
}
self.clearHistory();
for (out.turns) |turn| {
self.push(if (turn.role == .user) .user else .assistant, turn.text) catch {};
self.alloc.free(turn.text);
}
if (out.turns.len > 0) self.alloc.free(out.turns);
self.turns = self.userTurnCount();
if (self.session_name) |old| self.alloc.free(old);
self.session_name = out.session_name;
if (self.goal) |old| self.alloc.free(old);
self.goal = if (out.goal.len > 0) out.goal else null;
self.strict = out.strict;
self.ultracode = out.ultracode;
if (out.note.len > 0) {
self.push(.system, out.note) catch {};
self.alloc.free(out.note);
}
}

fn fakeResume(_: ?*anyopaque, gpa: std.mem.Allocator, spec: []const u8, out: *engine.ResumeOut) bool {
if (!std.mem.eql(u8, spec, "base --branch child")) return false;
const turns = gpa.alloc(engine.Turn, 2) catch return false;
turns[0] = .{ .role = .user, .text = gpa.dupe(u8, "baseline prompt") catch return false };
turns[1] = .{ .role = .assistant, .text = gpa.dupe(u8, "baseline answer") catch return false };
out.* = .{
.turns = turns,
.session_name = gpa.dupe(u8, "child") catch return false,
.note = gpa.dupe(u8, "branched base → child") catch return false,
};
return true;
}

test "fullscreen resume replaces transcript and selects the branch" {
const saved = engine.g_resume_fn;
defer engine.g_resume_fn = saved;
engine.g_resume_fn = fakeResume;
var model: app.Model = undefined;
model.setup(std.testing.allocator);
defer model.deinit();
try model.push(.user, "stale prompt");
run(&model, "base --branch child");
try std.testing.expectEqualStrings("child", model.session_name.?);
try std.testing.expectEqual(@as(usize, 3), model.history.items.len);
try std.testing.expectEqualStrings("baseline prompt", model.history.items[0].text);
try std.testing.expectEqualStrings("baseline answer", model.history.items[1].text);
try std.testing.expectEqualStrings("branched base → child", model.history.items[2].text);
}
9 changes: 9 additions & 0 deletions TUI/root.zig
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,17 @@ pub const CompactOut = engine.CompactOut;
pub const CompactFn = engine.CompactFn;
pub const HistoryOp = engine.HistoryOp;
pub const HistoryFn = engine.HistoryFn;
pub const ResumeOut = engine.ResumeOut;
pub const ResumeFn = engine.ResumeFn;
pub const SessionState = engine.SessionState;
pub const StateFn = engine.StateFn;
pub const PeerFn = engine.PeerFn;
pub const RunOpts = run_mod.RunOpts;
pub const run = run_mod.run;
pub fn setCurrentModel(name: []const u8, provider: []const u8) void {
engine.g_model_name = name;
engine.g_model_provider = provider;
}
pub const restore = @import("restore.zig");
pub const enable_seq = run_mod.enable_seq;

Expand All @@ -62,6 +70,7 @@ test {
_ = app;
_ = @import("app_tests.zig");
_ = @import("dispatch.zig");
_ = @import("resume.zig");
_ = @import("peer_cmd.zig");
_ = @import("peer_tests.zig");
_ = @import("prompt_history.zig");
Expand Down
15 changes: 15 additions & 0 deletions TUI/run.zig
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ pub fn run(
engine.g_copy_fn = opts.copy_fn;
engine.g_compact_fn = opts.compact_fn;
engine.g_history_fn = opts.history_fn;
engine.g_resume_fn = opts.resume_fn;
engine.g_state_fn = opts.state_fn;
engine.g_idle_wake_fn = opts.idle_wake_fn;
engine.g_peer_fn = opts.peer_fn;
engine.g_model_name = opts.model_name;
Expand All @@ -65,6 +67,18 @@ pub fn run(
var m: Model = undefined;
m.setup(gpa);
defer m.deinit();
defer if (opts.state_fn) |f| f(opts.turn_ctx, .{
.session_name = m.session_name orelse "",
.goal = m.goal orelse "",
.strict = m.strict,
.ultracode = m.ultracode,
});
for (opts.initial_history) |item| m.push(if (item.role == .user) .user else .assistant, item.text) catch {};
m.turns = m.userTurnCount();
if (opts.session_name.len > 0) m.session_name = gpa.dupe(u8, opts.session_name) catch null;
if (opts.initial_goal.len > 0) m.goal = gpa.dupe(u8, opts.initial_goal) catch null;
m.strict = opts.initial_strict;
m.ultracode = opts.initial_ultracode;
if (opts.yolo) m.mode = .always_approve;

const claimed = restore_mod.takeClaim();
Expand Down Expand Up @@ -439,6 +453,7 @@ pub fn run(
// The threads are still writing into the job and the op, so the
// process must not outlive the restore: put the terminal back with
// the same bytes the defers would have written, then leave.
if (opts.emergency_fn) |f| f(opts.turn_ctx);
w.flush() catch {};
restore_mod.emergency();
std.process.exit(0);
Expand Down
43 changes: 43 additions & 0 deletions docs/adr/0048-resume-branches-have-independent-durable-identity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# 0048. Resume branches have independent durable identity

Status: accepted 2026-08-30

## Context

Issue #689 reproduced silent loss when two processes resumed one session name.
The advisory writer lock serialized individual whole-file replacements, but it
could not turn two descendants into independent tips. It also exposed that the
fullscreen ACP-backed TUI rendered an empty conversation after startup resume
and bypassed the root final-save path.

Provider-native tool/reasoning blocks and compaction boundaries make merging two
message arrays unsafe. A process-lifetime source lock would avoid corruption by
rejecting the second user, but would not provide the requested branch behavior.

## Decision

`--resume SOURCE --branch DESTINATION` and
`/resume SOURCE --branch DESTINATION` clone SOURCE once into a new durable
identity. DESTINATION must be new and distinct from SOURCE; creation uses an
exclusive filesystem claim so two processes cannot both win a previously
unused name. Future turns, transcripts, checkpoints, compaction, and shutdown
saves target only DESTINATION; the session header records `parent: SOURCE` and
the branch receives a fresh persisted cache/session UUID.

The fullscreen TUI, TTY `graff repl`, scripted `graff repl`, and ACP startup all
consume the same restored provider-native history. The fullscreen frontend also
projects that history into visible rows and syncs its engine-owned conversation
back to the root before final save.

A branch copies ADR 0014's peer cursor and unread inbox snapshot exactly once as
part of the session snapshot. Parent and child then persist their cursors
independently; neither replays the room and neither shares mutable inbox state.
Git worktree isolation remains a separate choice.

## Consequences

Concurrent continuations need distinct destination names, which keeps conflicts
explicit and makes reopening deterministic. Existing `/resume SOURCE` remains a
same-tip continuation for compatibility and is not safe as a branching command.
There is no automatic merge; future merge/cherry-pick work must understand
provider-native history rather than appending JSON arrays.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ record only when you need the evidence or the edge cases.
| [0045](0045-glm-flash-swe-codegraff.md) | Codegraff `glm-5.3-flash` SWE: Pi 5/6 in 758s, graff 3/6 with three 300s timeouts; do not steal Pi's heap. |
| [0046](0046-flash-omits-default-effort.md) | Flash / Gemini send `reasoning_effort=low` (omit still thinks); lean `-p` shortens tool prose; `-p` streams. |
| [0047](0047-codegraff-swe-not-glm-only.md) | Codegraff SWE A/B is not GLM-only: Gemini graff 5/6 in 103s; DeepSeek flash still one-shots; do not steal Pi's catalog. |
| [0048](0048-resume-branches-have-independent-durable-identity.md) | `--resume SOURCE --branch DEST` clones provider history and peer cursor state once; every later save belongs only to DEST. |

## When to write one

Expand Down
Loading
Loading