Order the session list by when each agent was turned on
A running session no longer moves: the ones with a process come first, oldest start first, so starting one appends it to the bottom of that group and nothing it goes on to do -- beginning a turn, finishing one, asking a question -- can shift it. Sorting by activity with the awaiting-answer ones floated to the top is what this replaces; the status word and its colour already say which session wants something without the row having to move to say it. Stopped sessions are a group below, most recently active first. The order is the server's: `SessionConfig::started` is written each time a process is started for a session and reported as `started`, so it is the same on every device and survives a backend restart -- which adopts processes rather than starting them, and so could not work the times out for itself. Applied on the phone, because presentation order is a display decision. `LiveSession::info` takes the session's config entry rather than a parameter per field read from it, which is what `AutoResumeView` existed to bundle; that goes. Verified on the emulator against the sandbox: three echo sessions kept their order while the newest-active one was messaged; a stopped and restarted session moved below one started after it; a stopped session dropped below every running one; and after a backend restart the recorded times came back unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a9cfea89e5
commit
cbae7ee8c0
6 files changed
+255
-64
No files matched your search
@@ -1121,7 +1121,22 @@ Kotlin + Compose Multiplatform, single `:androidApp` module, same versions as
|
|||||||
dev-updater (Kotlin 2.4.x, CMP 1.11.x, JDK 21).
|
dev-updater (Kotlin 2.4.x, CMP 1.11.x, JDK 21).
|
||||||
|
|
||||||
1. **Session list** — kind icon, title, machine, model, status, last activity.
|
1. **Session list** — kind icon, title, machine, model, status, last activity.
|
||||||
Sessions awaiting an answer sort to the top: the "your turn" inbox.
|
**A session with a process stays where it is, and the order is when each
|
||||||
|
agent was turned on** (2026-09-15, replacing the awaiting-answer inbox
|
||||||
|
sort): the running sessions come first, oldest start first, so one that is
|
||||||
|
started joins the bottom of that group and nothing it goes on to do —
|
||||||
|
beginning a turn, finishing one, asking a question — can move it. A list
|
||||||
|
that reorders itself is one nobody can keep their place in, and the status
|
||||||
|
word and its colour already say which session wants an answer without the
|
||||||
|
row having to move to say it. Stopped sessions are a group below, most
|
||||||
|
recently active first; "turned on" is what the order above is made of and a
|
||||||
|
session with no process has no place in it.
|
||||||
|
The order is the server's (`SessionConfig::started`, reported as
|
||||||
|
`started`), written each time a process is started for the session, so it
|
||||||
|
is the same on every device and survives a backend restart — which adopts
|
||||||
|
processes rather than starting them, and so could not work the times out
|
||||||
|
for itself. Applied on the phone (`sessionsInListOrder`), because
|
||||||
|
presentation order is a display decision.
|
||||||
2. **Import** — Claude Code sessions the machine already has, selected in
|
2. **Import** — Claude Code sessions the machine already has, selected in
|
||||||
batches (hold to enter, tap to add), with Delete and Import along the
|
batches (hold to enter, tap to add), with Delete and Import along the
|
||||||
bottom. Submitting clears the selection immediately and marks every chosen
|
bottom. Submitting clears the selection immediately and marks every chosen
|
||||||
|
|||||||
@@ -225,6 +225,15 @@ data class SessionSummary(
|
|||||||
val usageProvider: String?,
|
val usageProvider: String?,
|
||||||
val status: String,
|
val status: String,
|
||||||
val lastActivity: Double,
|
val lastActivity: Double,
|
||||||
|
/**
|
||||||
|
* Epoch seconds a process was last started for this session -- when this agent was last turned
|
||||||
|
* on. What the list orders the running sessions by; see [sessionsInListOrder].
|
||||||
|
*
|
||||||
|
* The server's own, so the order is the same on every device and across a backend restart. An
|
||||||
|
* older server does not send it, and those sessions fall back to when the session was last
|
||||||
|
* active, which is the best this app can do without inventing a time.
|
||||||
|
*/
|
||||||
|
val started: Double,
|
||||||
/** Latest measured number of live background tasks; zero also covers older servers. */
|
/** Latest measured number of live background tasks; zero also covers older servers. */
|
||||||
val backgroundTasks: Int,
|
val backgroundTasks: Int,
|
||||||
/**
|
/**
|
||||||
@@ -265,6 +274,7 @@ private fun parseSession(session: JSONObject) =
|
|||||||
usageProvider = session.optString("usageProvider").ifEmpty { null },
|
usageProvider = session.optString("usageProvider").ifEmpty { null },
|
||||||
status = session.getString("status"),
|
status = session.getString("status"),
|
||||||
lastActivity = session.getDouble("lastActivity"),
|
lastActivity = session.getDouble("lastActivity"),
|
||||||
|
started = session.optDouble("started", session.getDouble("lastActivity")),
|
||||||
backgroundTasks = session.optInt("backgroundTasks", 0),
|
backgroundTasks = session.optInt("backgroundTasks", 0),
|
||||||
subagents = session.optInt("subagents", 0),
|
subagents = session.optInt("subagents", 0),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -257,12 +257,7 @@ fun SessionListScreen(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Awaiting-answer first (the point of the screen), then most recently active.
|
val ordered = sessionsInListOrder(state.value)
|
||||||
val ordered =
|
|
||||||
state.value.sortedWith(
|
|
||||||
compareByDescending<SessionSummary> { it.status == "awaitingInput" }
|
|
||||||
.thenByDescending { it.lastActivity }
|
|
||||||
)
|
|
||||||
LazyColumn {
|
LazyColumn {
|
||||||
uniqueItems(ordered, key = { it.id }) { session ->
|
uniqueItems(ordered, key = { it.id }) { session ->
|
||||||
SessionCard(
|
SessionCard(
|
||||||
@@ -472,6 +467,27 @@ fun SessionListScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The order sessions are drawn in: the ones with a process, oldest agent first, then the stopped
|
||||||
|
* ones, most recently active first. A display decision, made here rather than on the server, which
|
||||||
|
* answers in config order -- see UI_RULES.
|
||||||
|
*
|
||||||
|
* The running group is ordered by when each agent was *turned on* rather than by what it is doing,
|
||||||
|
* so a session cannot move by beginning a turn, finishing one or asking a question, and a list a
|
||||||
|
* reader has their place in stays still. That replaces sorting by activity and floating the
|
||||||
|
* awaiting-answer ones to the top: the status word and its colour already say which session wants
|
||||||
|
* something, without the row having to move to say it. [SessionSummary.started] is the server's, so
|
||||||
|
* the order is the same on every device and survives a backend restart.
|
||||||
|
*
|
||||||
|
* Stopped sessions are a group of their own because "turned on" is what the order above is made of.
|
||||||
|
* A session nobody could ask about is not one of them -- it has a process, and dropping it out of
|
||||||
|
* the running group the moment a machine goes quiet is the move this exists to prevent.
|
||||||
|
*/
|
||||||
|
fun sessionsInListOrder(sessions: List<SessionSummary>): List<SessionSummary> {
|
||||||
|
val (running, stopped) = sessions.partition { it.status != "exited" }
|
||||||
|
return running.sortedBy { it.started } + stopped.sortedByDescending { it.lastActivity }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The subagents picked out inside one session's card, and which card that is.
|
* The subagents picked out inside one session's card, and which card that is.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
class SessionOrderTest {
|
||||||
|
@Test
|
||||||
|
fun `running sessions keep the order their agents were turned on in`() {
|
||||||
|
val first = session("first", status = "running", started = 10.0, lastActivity = 900.0)
|
||||||
|
val second =
|
||||||
|
session("second", status = "awaitingInput", started = 20.0, lastActivity = 20.0)
|
||||||
|
val third = session("third", status = "waiting", started = 30.0, lastActivity = 500.0)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
listOf("first", "second", "third"),
|
||||||
|
sessionsInListOrder(listOf(third, second, first)).map { it.id },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a session started again joins the bottom of the running group`() {
|
||||||
|
val old = session("old", status = "idle", started = 10.0, lastActivity = 10.0)
|
||||||
|
val restarted = session("restarted", status = "idle", started = 99.0, lastActivity = 99.0)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
listOf("old", "restarted"),
|
||||||
|
sessionsInListOrder(listOf(restarted, old)).map { it.id },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `stopped sessions come after the running ones, most recent first`() {
|
||||||
|
val running = session("running", status = "idle", started = 100.0, lastActivity = 100.0)
|
||||||
|
val stale = session("stale", status = "exited", started = 1.0, lastActivity = 5.0)
|
||||||
|
val recent = session("recent", status = "exited", started = 2.0, lastActivity = 50.0)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
listOf("running", "recent", "stale"),
|
||||||
|
sessionsInListOrder(listOf(stale, recent, running)).map { it.id },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A session whose process nobody could ask about still has one, so it stays where it was rather
|
||||||
|
* than dropping into the stopped group the moment a machine goes quiet.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `a session of unknown state is one of the running ones`() {
|
||||||
|
val unknown = session("unknown", status = "unknown", started = 10.0, lastActivity = 10.0)
|
||||||
|
val stopped = session("stopped", status = "exited", started = 5.0, lastActivity = 999.0)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
listOf("unknown", "stopped"),
|
||||||
|
sessionsInListOrder(listOf(stopped, unknown)).map { it.id },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun session(id: String, status: String, started: Double, lastActivity: Double) =
|
||||||
|
SessionSummary(
|
||||||
|
id = id,
|
||||||
|
machine = "machine",
|
||||||
|
machineName = "machine",
|
||||||
|
provider = "echo",
|
||||||
|
title = id,
|
||||||
|
model = null,
|
||||||
|
keepsOwnTranscript = false,
|
||||||
|
ownTranscriptName = null,
|
||||||
|
permissionMode = null,
|
||||||
|
effort = null,
|
||||||
|
takesEffort = false,
|
||||||
|
imported = false,
|
||||||
|
notify = true,
|
||||||
|
autoResume = false,
|
||||||
|
autoResumeMessage = DEFAULT_RESUME_MESSAGE,
|
||||||
|
resumeAt = null,
|
||||||
|
cwd = null,
|
||||||
|
contextTokens = null,
|
||||||
|
maxImageEdge = null,
|
||||||
|
usageProvider = null,
|
||||||
|
status = status,
|
||||||
|
lastActivity = lastActivity,
|
||||||
|
started = started,
|
||||||
|
backgroundTasks = 0,
|
||||||
|
subagents = 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -392,6 +392,25 @@ pub struct SessionConfig {
|
|||||||
#[serde(default, skip_serializing_if = "not_set")]
|
#[serde(default, skip_serializing_if = "not_set")]
|
||||||
pub throwaway: bool,
|
pub throwaway: bool,
|
||||||
pub created: f64,
|
pub created: f64,
|
||||||
|
/// Epoch seconds a process was last started for this session, and `None`
|
||||||
|
/// for a session written before this was recorded.
|
||||||
|
///
|
||||||
|
/// The session list orders running sessions by it, so one that is turned
|
||||||
|
/// on joins the bottom of that group and stays where it is however busy
|
||||||
|
/// it gets. Persisted rather than held in memory because that order has
|
||||||
|
/// to survive a backend restart -- sessions are adopted, not restarted,
|
||||||
|
/// so nothing in the run that adopts them knows when they began.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub started: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionConfig {
|
||||||
|
/// When this session's agent was turned on. A session written before that
|
||||||
|
/// was recorded answers with its creation, which is when its first
|
||||||
|
/// process started.
|
||||||
|
pub fn started_at(&self) -> f64 {
|
||||||
|
self.started.unwrap_or(self.created)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A message owed to a session whose account ran out, and when to try sending
|
/// A message owed to a session whose account ran out, and when to try sending
|
||||||
@@ -598,6 +617,7 @@ mod tests {
|
|||||||
resume: None,
|
resume: None,
|
||||||
throwaway: false,
|
throwaway: false,
|
||||||
created: 1234.5,
|
created: 1234.5,
|
||||||
|
started: Some(2345.5),
|
||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
config.save(&path).expect("save");
|
config.save(&path).expect("save");
|
||||||
@@ -608,6 +628,8 @@ mod tests {
|
|||||||
// The label and the id are separate, and the session holds the id.
|
// The label and the id are separate, and the session holds the id.
|
||||||
assert_eq!(loaded.machine("vm").expect("machine").name, "the vm");
|
assert_eq!(loaded.machine("vm").expect("machine").name, "the vm");
|
||||||
assert_eq!(loaded.sessions[0].provider, "claude-cli");
|
assert_eq!(loaded.sessions[0].provider, "claude-cli");
|
||||||
|
// The list's order is made of this, so it has to survive the file.
|
||||||
|
assert_eq!(loaded.sessions[0].started, Some(2345.5));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
loaded
|
loaded
|
||||||
.machine("vm")
|
.machine("vm")
|
||||||
|
|||||||
+99
-57
@@ -139,29 +139,6 @@ pub enum NotificationKind {
|
|||||||
Finished,
|
Finished,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What a session's auto-resume setting looks like from outside: on or off,
|
|
||||||
/// what it would say, and when it next intends to check.
|
|
||||||
///
|
|
||||||
/// One struct rather than three parameters on [`LiveSession::info`], and read
|
|
||||||
/// from the config rather than from the launch snapshot beside it, for the
|
|
||||||
/// reason `cwd` is: all three change under a running session.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct AutoResumeView {
|
|
||||||
pub on: bool,
|
|
||||||
pub message: String,
|
|
||||||
pub at: Option<f64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AutoResumeView {
|
|
||||||
fn of(meta: &SessionConfig) -> Self {
|
|
||||||
Self {
|
|
||||||
on: meta.auto_resume,
|
|
||||||
message: resume_message(meta),
|
|
||||||
at: meta.resume.map(|scheduled| scheduled.at),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A session with a message owed to it once its account has quota again --
|
/// A session with a message owed to it once its account has quota again --
|
||||||
/// see [`SessionManager::owed_resumes`].
|
/// see [`SessionManager::owed_resumes`].
|
||||||
///
|
///
|
||||||
@@ -272,6 +249,10 @@ pub struct SessionInfo {
|
|||||||
pub status: SessionStatus,
|
pub status: SessionStatus,
|
||||||
pub last_activity: f64,
|
pub last_activity: f64,
|
||||||
pub created: f64,
|
pub created: f64,
|
||||||
|
/// Epoch seconds this session's agent was last turned on, which the
|
||||||
|
/// session list orders the running ones by -- see
|
||||||
|
/// [`SessionConfig::started`], whose fallback this resolves.
|
||||||
|
pub started: f64,
|
||||||
/// The provider's latest measured number of live background tasks.
|
/// The provider's latest measured number of live background tasks.
|
||||||
/// Absent until it reports one; absence is not a measured zero.
|
/// Absent until it reports one; absence is not a measured zero.
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
@@ -566,11 +547,12 @@ impl LiveSession {
|
|||||||
Ok((name, path))
|
Ok((name, path))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `machine_name` and `cwd` are passed in rather than read from the
|
/// `machine_name` and the session's own config entry are passed in
|
||||||
/// snapshot this session launched with: only the manager holds the
|
/// rather than read from the snapshot this session launched with: only
|
||||||
/// config, and both can change under a running session. Passed rather
|
/// the manager holds the config, and a setting, a working directory or
|
||||||
/// than mirrored into `Shared`, so there is one answer, read where the
|
/// the time of the latest start can all change under a running session.
|
||||||
/// row is built.
|
/// Passed rather than mirrored into `Shared`, so there is one answer,
|
||||||
|
/// read where the row is built.
|
||||||
///
|
///
|
||||||
/// `kind` rather than the facts derived from it, or every caller
|
/// `kind` rather than the facts derived from it, or every caller
|
||||||
/// derives each one separately. `None` where the provider has been
|
/// derives each one separately. `None` where the provider has been
|
||||||
@@ -579,11 +561,9 @@ impl LiveSession {
|
|||||||
fn info(
|
fn info(
|
||||||
&self,
|
&self,
|
||||||
machine_name: &str,
|
machine_name: &str,
|
||||||
cwd: Option<&Path>,
|
current: &SessionConfig,
|
||||||
effort: Option<&str>,
|
|
||||||
imported: bool,
|
imported: bool,
|
||||||
kind: Option<DriverKind>,
|
kind: Option<DriverKind>,
|
||||||
resume: AutoResumeView,
|
|
||||||
) -> SessionInfo {
|
) -> SessionInfo {
|
||||||
SessionInfo {
|
SessionInfo {
|
||||||
id: self.meta.id.clone(),
|
id: self.meta.id.clone(),
|
||||||
@@ -596,22 +576,23 @@ impl LiveSession {
|
|||||||
// From the config rather than from `shared`, like the cwd beside
|
// From the config rather than from `shared`, like the cwd beside
|
||||||
// it: neither can change under a running process, so there is no
|
// it: neither can change under a running process, so there is no
|
||||||
// live value for one to disagree with.
|
// live value for one to disagree with.
|
||||||
effort: effort.map(str::to_string),
|
effort: current.effort.clone(),
|
||||||
takes_effort: kind.is_some_and(DriverKind::takes_effort),
|
takes_effort: kind.is_some_and(DriverKind::takes_effort),
|
||||||
context_tokens: *self.shared.context_tokens.lock().unwrap(),
|
context_tokens: *self.shared.context_tokens.lock().unwrap(),
|
||||||
notify: *self.shared.notify.lock().unwrap(),
|
notify: *self.shared.notify.lock().unwrap(),
|
||||||
auto_resume: resume.on,
|
auto_resume: current.auto_resume,
|
||||||
auto_resume_message: resume.message,
|
auto_resume_message: resume_message(current),
|
||||||
resume_at: resume.at,
|
resume_at: current.resume.map(|scheduled| scheduled.at),
|
||||||
max_image_edge: kind.and_then(DriverKind::max_image_edge),
|
max_image_edge: kind.and_then(DriverKind::max_image_edge),
|
||||||
usage_provider: kind.and_then(DriverKind::usage_provider),
|
usage_provider: kind.and_then(DriverKind::usage_provider),
|
||||||
imported,
|
imported,
|
||||||
keeps_own_transcript: kind.is_some_and(DriverKind::keeps_own_transcript),
|
keeps_own_transcript: kind.is_some_and(DriverKind::keeps_own_transcript),
|
||||||
own_transcript_name: kind.and_then(DriverKind::own_transcript_name),
|
own_transcript_name: kind.and_then(DriverKind::own_transcript_name),
|
||||||
cwd: cwd.map(Path::to_path_buf),
|
cwd: current.cwd.clone(),
|
||||||
status: *self.shared.status.lock().unwrap(),
|
status: *self.shared.status.lock().unwrap(),
|
||||||
last_activity: *self.shared.last_activity.lock().unwrap(),
|
last_activity: *self.shared.last_activity.lock().unwrap(),
|
||||||
created: self.meta.created,
|
created: self.meta.created,
|
||||||
|
started: current.started_at(),
|
||||||
background_tasks: self.driver().and_then(|driver| driver.background_tasks()),
|
background_tasks: self.driver().and_then(|driver| driver.background_tasks()),
|
||||||
subagents: subagent::count(self.dir()),
|
subagents: subagent::count(self.dir()),
|
||||||
}
|
}
|
||||||
@@ -1107,11 +1088,9 @@ impl SessionManager {
|
|||||||
.map(|meta| match inner.live.get(&meta.id) {
|
.map(|meta| match inner.live.get(&meta.id) {
|
||||||
Some(session) => session.info(
|
Some(session) => session.info(
|
||||||
label_of(&inner.config, &meta.machine),
|
label_of(&inner.config, &meta.machine),
|
||||||
meta.cwd.as_deref(),
|
meta,
|
||||||
meta.effort.as_deref(),
|
|
||||||
import::read_cursor(&self.data_dir.join(&meta.id)).is_some(),
|
import::read_cursor(&self.data_dir.join(&meta.id)).is_some(),
|
||||||
kind_of(&inner.config, &meta.machine, &meta.provider),
|
kind_of(&inner.config, &meta.machine, &meta.provider),
|
||||||
AutoResumeView::of(meta),
|
|
||||||
),
|
),
|
||||||
None => SessionInfo {
|
None => SessionInfo {
|
||||||
id: meta.id.clone(),
|
id: meta.id.clone(),
|
||||||
@@ -1145,6 +1124,7 @@ impl SessionManager {
|
|||||||
status: status_of_unlaunched(&self.data_dir.join(&meta.id)),
|
status: status_of_unlaunched(&self.data_dir.join(&meta.id)),
|
||||||
last_activity: meta.created,
|
last_activity: meta.created,
|
||||||
created: meta.created,
|
created: meta.created,
|
||||||
|
started: meta.started_at(),
|
||||||
background_tasks: None,
|
background_tasks: None,
|
||||||
subagents: subagent::count(&self.data_dir.join(&meta.id)),
|
subagents: subagent::count(&self.data_dir.join(&meta.id)),
|
||||||
},
|
},
|
||||||
@@ -1223,6 +1203,7 @@ impl SessionManager {
|
|||||||
})?
|
})?
|
||||||
.clone();
|
.clone();
|
||||||
let id = unique_id(&inner.config);
|
let id = unique_id(&inner.config);
|
||||||
|
let created = now();
|
||||||
let title = spec
|
let title = spec
|
||||||
.title
|
.title
|
||||||
.filter(|title| !title.trim().is_empty())
|
.filter(|title| !title.trim().is_empty())
|
||||||
@@ -1268,7 +1249,10 @@ impl SessionManager {
|
|||||||
// whichever server is running when the time comes knows what to
|
// whichever server is running when the time comes knows what to
|
||||||
// do with it -- see `SessionConfig::throwaway`.
|
// do with it -- see `SessionConfig::throwaway`.
|
||||||
throwaway: self.spawn_throwaway,
|
throwaway: self.spawn_throwaway,
|
||||||
created: now(),
|
created,
|
||||||
|
// The spawn starts a process, so this session is turned on from
|
||||||
|
// the moment it exists.
|
||||||
|
started: Some(created),
|
||||||
};
|
};
|
||||||
|
|
||||||
let session = launch(
|
let session = launch(
|
||||||
@@ -1293,11 +1277,9 @@ impl SessionManager {
|
|||||||
// of the directory a moment later.
|
// of the directory a moment later.
|
||||||
let info = session.info(
|
let info = session.info(
|
||||||
&machine.name,
|
&machine.name,
|
||||||
session.meta.cwd.as_deref(),
|
&session.meta,
|
||||||
session.meta.effort.as_deref(),
|
|
||||||
import::read_cursor(&self.data_dir.join(&id)).is_some(),
|
import::read_cursor(&self.data_dir.join(&id)).is_some(),
|
||||||
Some(provider.kind),
|
Some(provider.kind),
|
||||||
AutoResumeView::of(&session.meta),
|
|
||||||
);
|
);
|
||||||
inner.live.insert(id, session);
|
inner.live.insert(id, session);
|
||||||
Ok(info)
|
Ok(info)
|
||||||
@@ -1931,6 +1913,18 @@ impl SessionManager {
|
|||||||
inner.live.insert(id.to_string(), session);
|
inner.live.insert(id.to_string(), session);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Turned on now, so it takes its place at the bottom of the running
|
||||||
|
// group -- see `SessionConfig::started`. Best effort: a config that
|
||||||
|
// cannot be written is not a reason to fail a start that has already
|
||||||
|
// happened, and the cost is one row in the wrong place.
|
||||||
|
let mut candidate = inner.config.clone();
|
||||||
|
if let Some(meta) = candidate.sessions.iter_mut().find(|meta| meta.id == id) {
|
||||||
|
meta.started = Some(now());
|
||||||
|
match candidate.save(&self.config_path) {
|
||||||
|
Ok(()) => inner.config = candidate,
|
||||||
|
Err(err) => tracing::warn!("couldn't record that session {id} started: {err:#}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
// Nothing is announced from here. A driver that starts a process
|
// Nothing is announced from here. A driver that starts a process
|
||||||
// reports the session idle itself, in order with everything else it
|
// reports the session idle itself, in order with everything else it
|
||||||
// says about that process. Saying it here too would be a second
|
// says about that process. Saying it here too would be a second
|
||||||
@@ -3219,20 +3213,7 @@ mod tests {
|
|||||||
// open to look one up on.
|
// open to look one up on.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
first.title,
|
first.title,
|
||||||
session
|
session.info("m", &session.meta, false, None).title
|
||||||
.info(
|
|
||||||
"m",
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
false,
|
|
||||||
None,
|
|
||||||
AutoResumeView {
|
|
||||||
on: false,
|
|
||||||
message: DEFAULT_RESUME_MESSAGE.to_string(),
|
|
||||||
at: None,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.title
|
|
||||||
);
|
);
|
||||||
|
|
||||||
manager.set_session_notify(&info.id, false).expect("off");
|
manager.set_session_notify(&info.id, false).expect("off");
|
||||||
@@ -4278,6 +4259,67 @@ mod tests {
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The session list keeps the running sessions in the order their agents
|
||||||
|
/// were turned on, so what that order is made of has to outlive both the
|
||||||
|
/// process and the backend: a session started again belongs at the bottom
|
||||||
|
/// of the group, and a restart adopts processes rather than starting them,
|
||||||
|
/// so nothing in the new run could work the time out for itself.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn starting_a_session_again_records_when_it_was_turned_on() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let config_path = dir.path().join("config.ron");
|
||||||
|
let data_dir = dir.path().join("sessions");
|
||||||
|
seed_echo_only(&config_path);
|
||||||
|
let manager = SessionManager::new(
|
||||||
|
config_path.clone(),
|
||||||
|
data_dir.clone(),
|
||||||
|
data_dir.join("models"),
|
||||||
|
)
|
||||||
|
.expect("manager");
|
||||||
|
let info = manager.spawn_session(echo_spec()).expect("spawn");
|
||||||
|
let spawned = info.started;
|
||||||
|
assert!(
|
||||||
|
(spawned - info.created).abs() < 1.0,
|
||||||
|
"a spawned session was turned on at {spawned} rather than when it was created",
|
||||||
|
);
|
||||||
|
|
||||||
|
let session = manager.session(&info.id).expect("live session");
|
||||||
|
let mut rx = session.subscribe();
|
||||||
|
let _ = session.sink.send(Event::Status {
|
||||||
|
state: SessionStatus::Exited,
|
||||||
|
});
|
||||||
|
collect_until(&mut rx, |event| {
|
||||||
|
matches!(
|
||||||
|
event,
|
||||||
|
Event::Status {
|
||||||
|
state: SessionStatus::Exited
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
// Otherwise the two starts can land in the same instant and the test
|
||||||
|
// cannot tell a recorded time from a kept one.
|
||||||
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||||
|
manager.start_session(&info.id).expect("start again");
|
||||||
|
collect_until(&mut rx, is_idle).await;
|
||||||
|
|
||||||
|
let restarted = manager.sessions()[0].started;
|
||||||
|
assert!(
|
||||||
|
restarted > spawned,
|
||||||
|
"starting the session again left it reporting {restarted}, the {spawned} of the \
|
||||||
|
process that had already ended",
|
||||||
|
);
|
||||||
|
drop(manager);
|
||||||
|
|
||||||
|
let manager = SessionManager::new(config_path, data_dir.clone(), data_dir.join("models"))
|
||||||
|
.expect("manager restart");
|
||||||
|
assert_eq!(
|
||||||
|
manager.sessions()[0].started,
|
||||||
|
restarted,
|
||||||
|
"the backend restart lost when this session's agent was turned on",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Stopping and starting a session is about its *process*, and the two
|
/// Stopping and starting a session is about its *process*, and the two
|
||||||
/// refusals are what keeps starting one from becoming a second one on the
|
/// refusals are what keeps starting one from becoming a second one on the
|
||||||
/// same conversation. Echo has no process, which makes it the right
|
/// same conversation. Echo has no process, which makes it the right
|
||||||
|
|||||||
Reference in new issue
Block a user