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
1 change: 1 addition & 0 deletions source/cydo/domain/tasks/model.d
Original file line number Diff line number Diff line change
Expand Up @@ -1303,6 +1303,7 @@ struct ServerStatusMessage
bool auth_enabled;
bool dev_mode;
string build_id;
string sidebar_sort;
}

struct ScanStatusMessage
Expand Down
17 changes: 17 additions & 0 deletions source/cydo/runtime/config/package.d
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,22 @@ struct WorkspaceConfig
@Optional ProjectDiscoveryConfig project_discovery;
}

/// How the sidebar orders tasks. `creation` is the tid order the list has
/// always had; `activity` puts the most recently active task at the top, a
/// parent rising with its most recently active descendant, with Archive and
/// Import below the live tasks.
enum SidebarSort { creation, activity }

struct SidebarConfig
{
@Optional SidebarSort sort = SidebarSort.creation;
}

struct UiConfig
{
@Optional SidebarConfig sidebar;
}

struct CydoConfig
{
@Key("name") WorkspaceConfig[] workspaces;
Expand All @@ -115,6 +131,7 @@ struct CydoConfig
@Optional bool dev_mode;
@Optional string log_level = "info";
@Optional string system_keyword = "SYSTEM";
@Optional UiConfig ui;

/// Called by configy during parsing (configy/read.d:650), so a semantic
/// error surfaces on the same path as a YAML syntax error.
Expand Down
18 changes: 15 additions & 3 deletions source/cydo/server/app.d
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import core.time : seconds;

import std.algorithm : sort;
import std.array : appender;
import std.conv : to;
import std.exception : enforce;
import std.file : exists, isFile, thisExePath;
import std.format : format;
Expand Down Expand Up @@ -53,6 +54,7 @@ import cydo.workflow.history.native_history : ConfiguredNativeHistoryContext,
TaskHistoryResolution, TaskHistoryResolutionKind, UnavailableHistory,
UnavailableHistoryKind, resolveNativeHistoryContext;
import cydo.workflow.history.abbrev : extractMessageText;
import cydo.workflow.history.last_turn : lastTurnStdTime;
import cydo.workflow.history.operations : CodexForkSourceState,
selectHistoryOperations;
import cydo.runtime.logging : installRobustLogger;
Expand Down Expand Up @@ -939,8 +941,14 @@ class App

resumeInFlightTasks();

// Recover last_active from .jsonl mtime for tasks that were alive
// when the backend crashed (last_active was cleared on session start).
// Recover last_active for tasks that were alive when the backend
// stopped (last_active was cleared on session start). The transcript's
// last conversation record is the activity time; its mtime is not,
// since records written around a session's start and exit (claude's
// last-prompt and cost-state, for example) touch the file without any
// work happening, so a restart moves every alive task's mtime. mtime
// stays the fallback for a transcript with no record lastTurnStdTime
// recognizes.
foreach (ref td; tasks)
{
if (td.lastActive == 0 && td.agentSessionId.length > 0)
Expand All @@ -954,7 +962,9 @@ class App
import std.file : exists, timeLastModified;
if (exists(jp))
{
td.lastActive = timeLastModified(jp).stdTime;
td.lastActive = lastTurnStdTime(jp);

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We might need to memoise this (on file size probably), startup performance may otherwise suffer greatly. Can we get a measurement of before/after startup time with 50 active tasks and a cold cache?

if (td.lastActive == 0)
td.lastActive = timeLastModified(jp).stdTime;
persistence.setLastActive(td.tid, td.lastActive);
}
}
Expand Down Expand Up @@ -1138,6 +1148,7 @@ class App
authUser.length > 0 || authPass.length > 0,
config.dev_mode,
webDistDir,
config.ui.sidebar.sort.to!string,
).representation));
ws.send(Data(buildNoticesList(activeNotices).representation));
if (discoveryService.scanInProgress)
Expand Down Expand Up @@ -3443,6 +3454,7 @@ class App
authUser.length > 0 || authPass.length > 0,
config.dev_mode,
webDistDir,
config.ui.sidebar.sort.to!string,
));
infof("Config reloaded successfully");
discoveryService.endScan();
Expand Down
4 changes: 3 additions & 1 deletion source/cydo/web/snapshots.d
Original file line number Diff line number Diff line change
Expand Up @@ -202,13 +202,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,
string sidebarSort = "creation")
{
return toJson(ServerStatusMessage(
"server_status",
authEnabled,
devMode,
readBuildId(webDistDir),
sidebarSort,
));
}

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

// When a task was last actually worked on, read from the tail of its
// transcript: the timestamp of its newest conversation record.
//
// Startup uses this to recover last_active for tasks that were alive when the
// backend stopped. The transcript's mtime does not answer that question:
// records written around a session's start and exit (claude's last-prompt and
// cost-state, for example) touch the file without any work happening, so a restart moves every alive task's mtime to the
// restart time. Those records are skipped here.

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;
}
Comment on lines +40 to +87

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And this should be a shared facility (but it sounds like you want to iterate over .jsonl lines in reverse order).


/// 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.
///
/// Only conversation records count, in the two shapes recognized:
/// claude: {"type":"user"|"assistant", ..., "timestamp":"..."}
/// codex: {"timestamp":"...", "type":"response_item", "payload":{...}}
/// Every other record type is skipped.
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;
}
Comment on lines +89 to +123

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please don't invent yet another place where we parse rollout files. This should be owned by the agent class.


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 not conversation: the reported
// time must be the last conversation record
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 conversation,
// session_meta and turn_context are not
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 other 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);
}
1 change: 1 addition & 0 deletions web/src/app.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ vi.mock("./useSessionManager", () => ({
getByTid: state.getByTid,
refreshWorkspaces: vi.fn(),
scanState: "idle",
sidebarSortByActivity: false,
}) satisfies TaskManager,
}));

Expand Down
5 changes: 4 additions & 1 deletion web/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ function AppContent() {
editRawEvent,
draftView,
sidebarTasks,
sidebarSortByActivity,
workspaces,
entryPoints,
typeInfo,
Expand Down Expand Up @@ -329,7 +330,7 @@ function AppContent() {
return;
}
if (!e.altKey || (e.key !== "ArrowUp" && e.key !== "ArrowDown")) return;
const order = flatTaskOrder(sidebarTasks);
const order = flatTaskOrder(sidebarTasks, sidebarSortByActivity);
if (e.shiftKey) {
// Jump to next/prev task with attention, wrapping around
if (order.length === 0) return;
Expand Down Expand Up @@ -377,6 +378,7 @@ function AppContent() {
};
}, [
sidebarTasks,
sidebarSortByActivity,
activeTaskId,
setActiveTaskId,
attention,
Expand Down Expand Up @@ -460,6 +462,7 @@ function AppContent() {
onOpenSearch={handleOpenSearch}
onArchive={handleSidebarArchive}
hasGlobalAttention={hasOtherProjectAttention}
sortByActivity={sidebarSortByActivity}
/>
{draftView && (
<div key={draftView.viewKey} style={{ display: "contents" }}>
Expand Down
Loading