blob: 2bb556aa412c0d3d95eea0c016d7f48ffe610f3a [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
use crate::StoreError;
use maka_agent_ports::session::history::TurnFinished;
use maka_agent_ports::session::history::TurnOutcome;
use maka_agent_ports::session::history::TurnTiming;
#[cfg(test)]
use sqlx::SqliteConnection;
use sqlx::sqlite::SqliteWorkerConnection;
// Two scalar index seeks, regardless of the size of a Turn's transcript. Start
// follows original sequence order, not the wall clock (which can move backward).
const SQL: &str = "WITH first AS (
SELECT timestamp AS at FROM transcript_directory
WHERE session_id = ?1 AND turn_id = ?2 AND source_sequence <= ?3
AND role = 'turn_state'
AND state_status = 'running'
ORDER BY source_sequence, item_index LIMIT 1
), last AS (
SELECT timestamp AS at,
state_status AS status FROM transcript_directory
WHERE session_id = ?1 AND turn_id = ?2 AND source_sequence <= ?3
AND role = 'turn_state'
ORDER BY source_sequence DESC, item_index DESC LIMIT 1
) SELECT first.at, last.at, last.status FROM first, last";
#[cfg(test)]
pub(super) async fn read(
tx: &mut SqliteConnection,
session: &str,
turn: &str,
through: i64,
) -> Result<Option<TurnTiming>, StoreError> {
let session = session.to_owned();
let turn = turn.to_owned();
crate::connection::worker_query(tx, move |worker| {
Box::pin(async move { read_on_worker(worker, &session, &turn, through).await })
})
.await
}
pub(super) async fn read_on_worker(
tx: &mut SqliteWorkerConnection<'_>,
session: &str,
turn: &str,
through: i64,
) -> Result<Option<TurnTiming>, StoreError> {
let row: Option<(Option<i64>, Option<i64>, String)> = sqlx::query_as(SQL)
.bind(session)
.bind(turn)
.bind(through)
.fetch_optional(tx)
.await?;
let Some((Some(start), Some(last), status)) = row else {
return Ok(None);
};
const MAX: i64 = (1 << 53) - 1;
if !(0..=MAX).contains(&start) || !(start..=MAX).contains(&last) {
return Ok(None);
}
let outcome = match status.as_str() {
"running" => None,
"completed" => Some(TurnOutcome::Completed),
"failed" => Some(TurnOutcome::Failed),
"aborted" => Some(TurnOutcome::Aborted),
_ => return Ok(None),
};
Ok(Some(TurnTiming {
started_at: start as u64,
finished: outcome.map(|outcome| TurnFinished {
at: last as u64,
outcome,
}),
}))
}
#[cfg(test)]
mod tests {
use super::*;
use sqlx::{Connection, Row};
#[tokio::test]
async fn turn_boundaries_are_scoped_fenced_and_do_not_restart_at_handoff_or_tail() {
let mut db = SqliteConnection::connect(":memory:").await.unwrap();
let schema = include_str!("../../migrations/0033_transcript_directory.sql");
sqlx::raw_sql(&schema[schema.find("CREATE TABLE transcript_directory (").unwrap()..])
.execute(&mut db)
.await
.unwrap();
for (session, turn, sequence, status, at) in [
("a", "t", 1, "running", 1000),
("a", "t", 2, "running", 2000),
("a", "t", 3, "completed", 69000),
("b", "t", 11, "running", 900),
("b", "t", 13, "failed", 99000),
("a", "other", 21, "running", 500),
("a", "other", 23, "aborted", 90000),
("a", "missing", 33, "completed", 5000),
("a", "backward", 41, "running", 5000),
("a", "backward", 42, "completed", 4999),
] {
sqlx::query("INSERT INTO transcript_directory (session_id,turn_id,source_sequence,item_index,message_id,timestamp,role,has_thinking,state_status) VALUES (?,?,?,0,?,?,'turn_state',0,?)")
.bind(session).bind(turn).bind(sequence).bind(format!("{turn}-{sequence}")).bind(at).bind(status)
.execute(&mut db)
.await
.unwrap();
}
let active = read(&mut db, "a", "t", 2).await.unwrap().unwrap();
assert_eq!(active.started_at, 1000);
assert_eq!(active.finished, None);
let done = read(&mut db, "a", "t", 3).await.unwrap().unwrap();
assert_eq!(done.started_at, 1000);
assert_eq!(
done.finished,
Some(TurnFinished {
at: 69000,
outcome: TurnOutcome::Completed
})
);
assert_eq!(read(&mut db, "a", "t", 2).await.unwrap(), Some(active));
// Later rows in other sessions/Turns cannot change this completed Turn.
assert_eq!(read(&mut db, "a", "t", 42).await.unwrap(), Some(done));
for (session, turn, through, outcome) in [
("b", "t", 13, TurnOutcome::Failed),
("a", "other", 23, TurnOutcome::Aborted),
] {
assert_eq!(
read(&mut db, session, turn, through)
.await
.unwrap()
.unwrap()
.finished
.unwrap()
.outcome,
outcome
);
}
for (turn, through) in [("absent", 42), ("missing", 33), ("backward", 42)] {
assert_eq!(read(&mut db, "a", turn, through).await.unwrap(), None);
}
let sql = format!("EXPLAIN QUERY PLAN {SQL}");
let plan = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
.bind("a")
.bind("t")
.bind(3i64)
.fetch_all(&mut db)
.await
.unwrap()
.into_iter()
.map(|row| row.get::<String, _>(3))
.collect::<Vec<_>>()
.join("\n");
assert_eq!(
plan.matches("USING INDEX directory_state").count(),
2,
"{plan}"
);
}
}