Skip to content
Open
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: 14 additions & 3 deletions source/cydo/domain/storage/persistence.d
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,11 @@ struct Persistence
"ALTER TABLE tasks ADD COLUMN needs_attention INTEGER NOT NULL DEFAULT 0;",
// Migration 20: immutable task-local commit collection boundary
"ALTER TABLE tasks ADD COLUMN task_start_head TEXT NOT NULL DEFAULT '';",
// Migration: when the task was last actually worked on, as StdTime.
// Distinct from last_active, which is cleared on session start for
// crash recovery and so cannot survive a restart. Treated as a
// cache: recomputed from each transcript's tail at startup.
"ALTER TABLE tasks ADD COLUMN last_turn_at INTEGER NOT NULL DEFAULT 0;",
]);

// In CI, disable durability to speed up tests. This trades crash-safety
Expand Down Expand Up @@ -273,6 +278,7 @@ struct Persistence
long lastActive;
string entryPoint;
bool needsAttention;
long lastTurnAt;
}

TaskRow[] loadTasks()
Expand All @@ -281,10 +287,10 @@ struct Persistence
foreach (int tid, string agentSessionId, string description, string taskType,
int parentTid, string relationType, string workspace, string projectPath,
int worktreeTid, string taskStartHead, string title, string status, string agentType, int archived, string draft,
string resultText, long createdAt, long lastActive, string entryPoint, int needsAttention;
db.stmt!"SELECT tid, COALESCE(agent_session_id,''), COALESCE(description,''), COALESCE(task_type,'blank'), COALESCE(parent_tid,0), COALESCE(relation_type,''), COALESCE(workspace,''), COALESCE(project_path,''), COALESCE(worktree_tid,0), COALESCE(task_start_head,''), COALESCE(title,''), COALESCE(status,'completed'), COALESCE(agent_type,'claude'), COALESCE(archived,0), COALESCE(draft,''), COALESCE(result_text,''), COALESCE(created_at,0), COALESCE(last_active,0), COALESCE(entry_point,''), COALESCE(needs_attention,0) FROM tasks".iterate())
string resultText, long createdAt, long lastActive, string entryPoint, int needsAttention, long lastTurnAt;
db.stmt!"SELECT tid, COALESCE(agent_session_id,''), COALESCE(description,''), COALESCE(task_type,'blank'), COALESCE(parent_tid,0), COALESCE(relation_type,''), COALESCE(workspace,''), COALESCE(project_path,''), COALESCE(worktree_tid,0), COALESCE(task_start_head,''), COALESCE(title,''), COALESCE(status,'completed'), COALESCE(agent_type,'claude'), COALESCE(archived,0), COALESCE(draft,''), COALESCE(result_text,''), COALESCE(created_at,0), COALESCE(last_active,0), COALESCE(entry_point,''), COALESCE(needs_attention,0), COALESCE(last_turn_at,0) FROM tasks".iterate())
{
result ~= TaskRow(tid, agentSessionId, description, taskType, parentTid, relationType, workspace, projectPath, worktreeTid, taskStartHead, title, status, agentType, archived != 0, draft, resultText, createdAt, lastActive, entryPoint, needsAttention != 0);
result ~= TaskRow(tid, agentSessionId, description, taskType, parentTid, relationType, workspace, projectPath, worktreeTid, taskStartHead, title, status, agentType, archived != 0, draft, resultText, createdAt, lastActive, entryPoint, needsAttention != 0, lastTurnAt);
}
return result;
}
Expand Down Expand Up @@ -343,6 +349,11 @@ struct Persistence
db.stmt!"UPDATE tasks SET result_text = ? WHERE tid = ?".exec(resultText, tid);
}

void setLastTurnAt(int tid, long lastTurnAt)
{
db.stmt!"UPDATE tasks SET last_turn_at = ? WHERE tid = ?".exec(lastTurnAt, tid);
}

void setLastActive(int tid, long lastActive)
{
db.stmt!"UPDATE tasks SET last_active = ? WHERE tid = ?".exec(lastActive, tid);
Expand Down
6 changes: 6 additions & 0 deletions source/cydo/domain/tasks/model.d
Original file line number Diff line number Diff line change
Expand Up @@ -541,6 +541,10 @@ struct TaskData
bool archived;
long createdAt; // StdTime; 0 = not set
long lastActive; // StdTime; 0 = not set
/// When this task was last actually worked on (StdTime), for recency
/// ordering. Unlike lastActive it is never cleared by session lifecycle,
/// and is recomputed from the transcript tail at startup.
long lastTurnAt;

/// Git repository root for the selected project.
/// Falls back to projectPath if git resolution fails.
Expand Down Expand Up @@ -1015,6 +1019,7 @@ struct TaskListEntry
string task_type;
string entry_point;
string agent_name;
long last_turn_at;
bool archived;
bool archiving; // true while an archive/unarchive transition is in progress
string draft;
Expand Down Expand Up @@ -1098,6 +1103,7 @@ struct ServerStatusMessage
bool auth_enabled;
bool dev_mode;
string build_id;
bool sidebar_sort_by_recency;
}

struct ScanStatusMessage
Expand Down
5 changes: 5 additions & 0 deletions source/cydo/runtime/config/package.d
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ struct CydoConfig
@Optional bool dev_mode;
@Optional string log_level = "info";
@Optional string system_keyword = "SYSTEM";
/// Order the sidebar by activity rather than creation: the most recently
/// worked-on task sits at the top and the order updates as tasks are used,
/// a parent rising with its most recent descendant. Archive and Import move
/// below the live tasks. Off keeps the creation-ordered list.
@Optional bool sidebar_sort_by_recency;
}

string configPath()
Expand Down
37 changes: 36 additions & 1 deletion source/cydo/server/app.d
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import cydo.web.snapshots : buildAgentsList, buildNoticesList,
import cydo.workflow.history.pipeline : HistoryBroadcastPlan, HistoryEventPipeline,
HistoryEventPipelineHost;
import cydo.workflow.history.abbrev : extractMessageText;
import cydo.workflow.history.last_turn : lastTurnStdTime;
import cydo.workflow.history.operations : selectHistoryOperations;
import cydo.runtime.logging : installRobustLogger;
import cydo.workflow.system_message_normalizer : SystemMessageNormalizer,
Expand Down Expand Up @@ -787,6 +788,7 @@ class App
td.createdAt = row.createdAt;
td.lastActive = row.lastActive;
td.needsAttention = row.needsAttention;
td.lastTurnAt = row.lastTurnAt;
td.titleGenDone = row.title.length > 0;
auto rowTid = row.tid;
tasks[rowTid] = move(td);
Expand Down Expand Up @@ -887,6 +889,32 @@ class App
// Final fallback: if still no lastActive but has createdAt, use that
if (td.lastActive == 0 && td.createdAt != 0)
td.lastActive = td.createdAt;

// Recompute when the task was last actually worked on. Always, not
// just when unset: the stored value is a cache, and re-deriving it
// from the transcript keeps a task that went stale from staying
// stale until it happens to be used again. Resumes append session
// records rather than turns, so this steps over the restart.
if (td.agentSessionId.length > 0)
{
try
{
auto ta = agentForTask(td.tid);
auto jp = ta.historyPath(td.agentSessionId, taskPathResolver.effectiveCwd(&td));
if (jp.length > 0)
{
auto turnAt = lastTurnStdTime(jp);
if (turnAt != 0 && turnAt != td.lastTurnAt)
{
td.lastTurnAt = turnAt;
persistence.setLastTurnAt(td.tid, turnAt);
}
}
}
catch (Exception) {} // best-effort; falls back to createdAt below
}
if (td.lastTurnAt == 0)
td.lastTurnAt = td.createdAt;
}

discoveryService.enumerateSessions(config, agentsByName);
Expand Down Expand Up @@ -1058,6 +1086,7 @@ class App
authUser.length > 0 || authPass.length > 0,
config.dev_mode,
webDistDir,
config.sidebar_sort_by_recency,
).representation));
ws.send(Data(buildNoticesList(activeNotices).representation));
if (discoveryService.scanInProgress)
Expand Down Expand Up @@ -2861,6 +2890,7 @@ class App
authUser.length > 0 || authPass.length > 0,
config.dev_mode,
webDistDir,
config.sidebar_sort_by_recency,
));
infof("Config reloaded successfully");
discoveryService.endScan();
Expand Down Expand Up @@ -2920,7 +2950,12 @@ class App
private void touchTask(int tid)
{
import std.datetime : Clock;
tasks[tid].lastActive = Clock.currStdTime;
auto now = Clock.currStdTime;
tasks[tid].lastActive = now;
// real activity (a message sent, a turn finished), never session
// lifecycle, so this is safe to persist and survives restarts
tasks[tid].lastTurnAt = now;
persistence.setLastTurnAt(tid, now);
}

private AgentSession sessionForTask(int tid)
Expand Down
7 changes: 5 additions & 2 deletions source/cydo/web/snapshots.d
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ TaskListEntry buildTaskEntry(ref TaskData td, bool alive, bool canStop)
td.agentSessionId.length > 0 && !alive && td.status != "importable",
td.isProcessing, td.stdinClosed, canStop, td.needsAttention, td.hasPendingQuestion, td.notificationBody,
td.title, td.workspace, td.projectPath, td.parentTid, td.relationType, cast(string) td.status,
td.taskType, td.entryPoint, td.agentType, td.archived, td.archiving, td.draft, td.error,
td.taskType, td.entryPoint, td.agentType,
stdTimeToUnixMillis(td.lastTurnAt), td.archived, td.archiving, td.draft, td.error,
stdTimeToUnixMillis(td.createdAt), stdTimeToUnixMillis(td.lastActive));
}

Expand Down Expand Up @@ -83,13 +84,15 @@ string readBuildId(string webDistDir)
return m[1].idup;
}

string buildServerStatus(bool authEnabled, bool devMode, string webDistDir)
string buildServerStatus(bool authEnabled, bool devMode, string webDistDir,
bool sidebarSortByRecency = false)
{
return toJson(ServerStatusMessage(
"server_status",
authEnabled,
devMode,
readBuildId(webDistDir),
sidebarSortByRecency,
));
}

Expand Down
185 changes: 185 additions & 0 deletions source/cydo/workflow/history/last_turn.d
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
module cydo.workflow.history.last_turn;

// When a task was last actually worked on, read from the tail of its
// transcript.
//
// The obvious signals do not survive a backend restart. The startup sweep
// resumes every in-flight session, and each resume appends to that session's
// transcript, so the file's mtime becomes the restart time for every task at
// once. last_active is worse still: it is cleared on session start and
// recovered from that same mtime, so a restart collapses every task onto one
// timestamp and the ordering it feeds is noise.
//
// A resume writes session and meta records, never a conversation turn, so the
// newest user/assistant record in the file steps over the restart entirely.
// That is the value this module recovers.

import std.datetime.systime : SysTime;

/// Newest user/assistant timestamp in a transcript, as StdTime. Returns 0 when
/// the file is missing, unreadable, or holds no conversation turn (an empty or
/// resume-only transcript), which callers treat as "unknown" and fall back on.
///
/// Only the tail is read: transcripts run to tens of megabytes and this is
/// called for every task at startup. The window grows if the tail holds no
/// turn, so a session that was resumed repeatedly without being used still
/// resolves rather than silently reporting 0.
long lastTurnStdTime(string path, size_t maxBytes = 8 << 20) nothrow
{
static immutable size_t[] windows = [64 << 10, 1 << 20, 8 << 20];
foreach (window; windows)
{
if (window > maxBytes)
break;
bool wholeFile;
auto found = scanTail(path, window, wholeFile);
if (found != 0 || wholeFile)
return found;
}
return 0;
}

/// Scan the last `window` bytes for the newest conversation turn. Sets
/// `wholeFile` when the window covered the entire file, so the caller knows a
/// miss is final rather than an artifact of the window size.
private long scanTail(string path, size_t window, out bool wholeFile) nothrow
{
import std.stdio : File;

wholeFile = false;
try
{
auto f = File(path, "rb");
scope(exit) f.close();
auto size = f.size();
if (size == 0)
{
wholeFile = true;
return 0;
}

ulong start = size > window ? size - window : 0;
wholeFile = start == 0;
f.seek(start);
auto buf = new ubyte[cast(size_t)(size - start)];
auto chunk = f.rawRead(buf);

auto text = cast(string) chunk.idup;
// a mid-file window almost certainly starts inside a record; that
// partial first line would fail to parse anyway, but dropping it keeps
// the intent explicit
if (!wholeFile)
{
import std.string : indexOf;
auto nl = text.indexOf('\n');
text = nl < 0 ? "" : text[nl + 1 .. $];
}

long newest = 0;
import std.algorithm : splitter;
foreach (line; text.splitter('\n'))
{
auto ts = turnTimestamp(line);
if (ts > newest)
newest = ts;
}
return newest;
}
catch (Exception)
return 0;
catch (Error)
return 0;
}

/// StdTime of one transcript line, or 0 if it is not a conversation turn.
///
/// Parsed by hand rather than by deserializing: this runs over every line of
/// every task's tail at startup, and the records carry large nested payloads
/// that would be built and thrown away.
///
/// The agents write different shapes, so both are recognized:
/// claude: {"type":"user"|"assistant", ..., "timestamp":"..."}
/// codex: {"timestamp":"...", "type":"response_item", "payload":{...}}
/// What matters either way is excluding the records a resume writes (claude's
/// summary and queue-operation, codex's session_meta and turn_context), since
/// counting those is what made every task look equally recent.
private long turnTimestamp(const(char)[] line) nothrow
{
import std.string : indexOf;

if (line.length == 0)
return 0;
if (line.indexOf(`"type":"user"`) < 0
&& line.indexOf(`"type":"assistant"`) < 0
&& line.indexOf(`"type":"response_item"`) < 0)
return 0;

auto key = line.indexOf(`"timestamp":"`);
if (key < 0)
return 0;
auto valueStart = key + `"timestamp":"`.length;
auto rest = line[valueStart .. $];
auto close = rest.indexOf('"');
if (close < 0)
return 0;

try
return SysTime.fromISOExtString(rest[0 .. close]).stdTime;
catch (Exception)
return 0;
}

unittest
{
import std.file : write, remove, tempDir;
import std.path : buildPath;
import std.exception : collectException;

auto path = buildPath(tempDir(), "cydo-last-turn-test.jsonl");
scope(exit) collectException(remove(path));

// a transcript whose newest records are the meta ones a resume writes:
// the reported time must be the last real turn, not the resume
write(path,
`{"type":"user","timestamp":"2026-07-20T10:00:00.000Z"}` ~ "\n" ~
`{"type":"assistant","timestamp":"2026-07-25T18:52:09.643Z"}` ~ "\n" ~
`{"type":"summary","timestamp":"2026-07-27T22:50:00.000Z"}` ~ "\n" ~
`{"type":"queue-operation","timestamp":"2026-07-27T22:50:01.000Z"}` ~ "\n");
auto expected = SysTime.fromISOExtString("2026-07-25T18:52:09.643Z").stdTime;
assert(lastTurnStdTime(path) == expected);

// a transcript with no turns at all reports unknown rather than guessing
write(path, `{"type":"session","timestamp":"2026-07-27T22:50:00.000Z"}` ~ "\n");
assert(lastTurnStdTime(path) == 0);

// malformed lines are skipped, not fatal
write(path,
"not json\n" ~
`{"type":"user","timestamp":"garbage"}` ~ "\n" ~
`{"type":"user","timestamp":"2026-07-26T08:00:00.000Z"}` ~ "\n");
assert(lastTurnStdTime(path) ==
SysTime.fromISOExtString("2026-07-26T08:00:00.000Z").stdTime);

assert(lastTurnStdTime(buildPath(tempDir(), "cydo-no-such-file.jsonl")) == 0);

// codex writes a different shape: response_item is the real work, while
// session_meta and turn_context are what a resume leaves behind
write(path,
`{"timestamp":"2026-07-21T21:51:07.249Z","type":"response_item","payload":{"type":"message","role":"assistant"}}` ~ "\n" ~
`{"timestamp":"2026-07-27T23:50:00.000Z","type":"session_meta","payload":{"session_id":"x"}}` ~ "\n" ~
`{"timestamp":"2026-07-27T23:50:01.000Z","type":"turn_context","payload":{"cwd":"/tmp/project"}}` ~ "\n");
assert(lastTurnStdTime(path) ==
SysTime.fromISOExtString("2026-07-21T21:51:07.249Z").stdTime);

// a turn buried behind more than the first window of resume records is
// still found, because the window grows
import std.array : replicate;
string padded;
padded ~= `{"type":"user","timestamp":"2026-07-26T08:00:00.000Z"}` ~ "\n";
foreach (i; 0 .. 2000)
padded ~= `{"type":"session","timestamp":"2026-07-27T22:50:00.000Z","pad":"`
~ "x".replicate(64) ~ `"}` ~ "\n";
write(path, padded);
assert(lastTurnStdTime(path) ==
SysTime.fromISOExtString("2026-07-26T08:00:00.000Z").stdTime);
}
Loading