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
21 changes: 16 additions & 5 deletions source/cydo/domain/storage/persistence.d
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,11 @@ struct Persistence
" has_messages INTEGER NOT NULL DEFAULT 1," ~
" PRIMARY KEY (driver, profile_root, session_id)" ~
");",
// Migration 22: 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 @@ -292,6 +297,7 @@ struct Persistence
long lastActive;
string entryPoint;
bool needsAttention;
long lastTurnAt;
}

TaskRow[] loadTasks()
Expand All @@ -300,11 +306,11 @@ 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 agentName, 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())
{
// tasks.agent_type stores the configured agent name from config.agents.
result ~= TaskRow(tid, agentSessionId, description, taskType, parentTid, relationType, workspace, projectPath, worktreeTid, taskStartHead, title, status, agentName, archived != 0, draft, resultText, createdAt, lastActive, entryPoint, needsAttention != 0);
result ~= TaskRow(tid, agentSessionId, description, taskType, parentTid, relationType, workspace, projectPath, worktreeTid, taskStartHead, title, status, agentName, archived != 0, draft, resultText, createdAt, lastActive, entryPoint, needsAttention != 0, lastTurnAt);
}
return result;
}
Expand Down Expand Up @@ -364,6 +370,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 Expand Up @@ -610,7 +621,7 @@ unittest
int userVersion;
foreach (int value; persistence.db.stmt!"PRAGMA user_version".iterate())
userVersion = value;
assert(userVersion == 22);
assert(userVersion == 23);

auto rows = persistence.loadTasks();
assert(rows.length == 1);
Expand All @@ -629,7 +640,7 @@ unittest
"relation_type", "workspace", "project_path", "title", "status",
"worktree_path", "has_worktree", "agent_type", "archived", "draft",
"result_text", "created_at", "last_active", "worktree_tid", "entry_point",
"needs_attention", "task_start_head",
"needs_attention", "task_start_head", "last_turn_at",
]);

persistence.upsertSessionMetaCache("claude", "/profiles/one", "same-id", 1,
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 @@ -557,6 +557,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 @@ -1086,6 +1090,7 @@ struct TaskListEntry
string entry_point;
string agent_name;
string driver; // resolved runtime driver ("claude"/"codex"/"copilot"); empty for orphaned agents
long last_turn_at;
bool archived;
bool archiving; // true while an archive/unarchive transition is in progress
string draft;
Expand Down Expand Up @@ -1170,6 +1175,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 @@ -115,6 +115,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;

/// 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
39 changes: 38 additions & 1 deletion source/cydo/server/app.d
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,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 @@ -873,6 +874,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 @@ -960,6 +962,34 @@ 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 resolution = resolveTaskHistory(td.tid);
auto jp = resolution.kind == TaskHistoryResolutionKind.access
? resolution.requireAccess().path
: "";
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();
Expand Down Expand Up @@ -1136,6 +1166,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 @@ -3434,6 +3465,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 @@ -3523,7 +3555,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
9 changes: 6 additions & 3 deletions source/cydo/web/snapshots.d
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ TaskListEntry buildTaskEntry(ref TaskData td, size_t childCount, bool alive,
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, childCount, td.relationType, cast(string) td.status,
td.taskType, td.entryPoint, td.agentName, driver, td.archived, td.archiving, td.draft, td.error,
td.taskType, td.entryPoint, td.agentName, driver,
stdTimeToUnixMillis(td.lastTurnAt), td.archived, td.archiving, td.draft, td.error,
stdTimeToUnixMillis(td.createdAt), stdTimeToUnixMillis(td.lastActive));
}

Expand Down Expand Up @@ -202,13 +203,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 Expand Up @@ -299,7 +302,7 @@ unittest
}

auto exact = buildTasksList([entry(1, 0, false, false, "completed", 2)], true);
assert(exact == `{"type":"tasks_list","complete":true,"tasks":[{"tid":1,"alive":false,"resumable":false,"isProcessing":false,"stdinClosed":false,"canStop":false,"needsAttention":false,"hasPendingQuestion":false,"notificationBody":null,"title":null,"workspace":null,"project_path":null,"parent_tid":0,"child_count":2,"relation_type":null,"status":"completed","task_type":null,"entry_point":null,"agent_name":null,"driver":null,"archived":false,"archiving":false,"draft":null,"error":null,"created_at":0,"last_active":0}]}`,
assert(exact == `{"type":"tasks_list","complete":true,"tasks":[{"tid":1,"alive":false,"resumable":false,"isProcessing":false,"stdinClosed":false,"canStop":false,"needsAttention":false,"hasPendingQuestion":false,"notificationBody":null,"title":null,"workspace":null,"project_path":null,"parent_tid":0,"child_count":2,"relation_type":null,"status":"completed","task_type":null,"entry_point":null,"agent_name":null,"driver":null,"last_turn_at":0,"archived":false,"archiving":false,"draft":null,"error":null,"created_at":0,"last_active":0}]}`,
exact);
assert(!exact.canFind(`"stage"`), exact);
assert(buildTasksList([], false).canFind(`"complete":false`));
Expand Down
Loading