Use native Codex steering and transcript deletion
This commit is contained in:
1 parent
00538cc19b
commit
8c88a7e991
12 files changed
+1004
-390
No files matched your search
+12
-4
@@ -156,8 +156,7 @@ pub enum DriverKind {
|
||||
/// The Claude Code CLI over stream-json. Named for the CLI specifically:
|
||||
/// bare "claude" would suggest the credit-billed API, which this is not.
|
||||
ClaudeCli,
|
||||
/// The Codex CLI's `exec --json` JSONL stream. Each process is one turn;
|
||||
/// the thread id it reports is resumed by the next process.
|
||||
/// The Codex CLI's persistent app-server JSONL protocol.
|
||||
CodexCli,
|
||||
}
|
||||
|
||||
@@ -235,9 +234,18 @@ impl DriverKind {
|
||||
/// the one sentence that has to be true: said of a session that can in fact
|
||||
/// be brought back, it spends the credibility the warning needs.
|
||||
pub fn keeps_own_transcript(self) -> bool {
|
||||
self.own_transcript_name().is_some()
|
||||
}
|
||||
|
||||
/// The product whose durable transcript survives an ordinary app delete.
|
||||
/// Reported to the phone because a provider's configured name is not the
|
||||
/// name of its storage, and calling Codex's rollout a Claude transcript is
|
||||
/// especially misleading on an irreversible switch.
|
||||
pub fn own_transcript_name(self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::ClaudeCli | Self::CodexCli => true,
|
||||
Self::Echo | Self::LlamaCpp => false,
|
||||
Self::ClaudeCli => Some("Claude Code"),
|
||||
Self::CodexCli => Some("Codex"),
|
||||
Self::Echo | Self::LlamaCpp => None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+10
-13
@@ -1130,8 +1130,8 @@ async fn spawn(manager: &Arc<SessionManager>, body: SpawnRequest) -> Result<Sess
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DeleteSessionQuery {
|
||||
/// Also remove the machine's own transcript of this conversation -- the
|
||||
/// file Claude Code keeps under `~/.claude/projects`, which this server's
|
||||
/// delete does not otherwise touch.
|
||||
/// provider-owned file (for example Claude Code's project JSONL or a Codex
|
||||
/// rollout), which this server's ordinary delete does not otherwise touch.
|
||||
///
|
||||
/// Off by default, because the two deletes differ in what they cost:
|
||||
/// leaving the machine's copy is recoverable and removing it is not, and a
|
||||
@@ -1154,18 +1154,15 @@ async fn delete_session(
|
||||
// And *deleted* before it too, so a machine that cannot be reached leaves
|
||||
// everything as it was rather than a deleted session and a transcript the
|
||||
// phone has already promised is gone.
|
||||
if let Some((setup, session)) = &foreign {
|
||||
let setup = setup_by_id(&manager, setup)?;
|
||||
if let Some(foreign) = &foreign {
|
||||
let setup = setup_by_id(&manager, &foreign.setup)?;
|
||||
let transport = crate::session::transport::Transport::for_setup(&setup);
|
||||
// A batch of one: the same call, so there is one description of what
|
||||
// deleting a foreign transcript means.
|
||||
crate::session::import::delete(&transport, std::slice::from_ref(session))
|
||||
.await
|
||||
.map_err(bad_request)?
|
||||
.remove(session)
|
||||
.unwrap_or_else(|| Err(format!("nothing was reported about {session}")))
|
||||
.map_err(|message| bad_request(anyhow::anyhow!("{message}")))?;
|
||||
tracing::info!("deleted Claude Code session {session} with ai-app session {id}");
|
||||
foreign.delete(&transport).await.map_err(bad_request)?;
|
||||
tracing::info!(
|
||||
"deleted {} session {} with ai-app session {id}",
|
||||
foreign.owner_name(),
|
||||
foreign.id
|
||||
);
|
||||
}
|
||||
manager.delete_session(&id).map_err(bad_request)?;
|
||||
tracing::info!("deleted session {id}");
|
||||
|
||||
@@ -47,7 +47,6 @@
|
||||
//! same way as the rest, against 2.1.237 on 2026-08-29.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
@@ -393,9 +392,9 @@ impl ClaudeDriver {
|
||||
|
||||
// Fresh logs, because the offsets that index them start at zero and
|
||||
// everything the previous process said is already in the transcript.
|
||||
let stdin = make_fifo(&session_dir.join(STDIN_FIFO))?;
|
||||
let stdout = create_log(&session_dir.join(STDOUT_LOG))?;
|
||||
let stderr = create_log(&session_dir.join(STDERR_LOG))?;
|
||||
let stdin = process::make_fifo(&session_dir.join(STDIN_FIFO))?;
|
||||
let stdout = process::create_log(&session_dir.join(STDOUT_LOG))?;
|
||||
let stderr = process::create_log(&session_dir.join(STDERR_LOG))?;
|
||||
|
||||
let program = provider.program();
|
||||
let launch = Launch::new(program, args, meta.cwd.as_deref());
|
||||
@@ -1049,44 +1048,6 @@ fn stderr_tail(path: &Path) -> String {
|
||||
tail_of(&kept)
|
||||
}
|
||||
|
||||
/// Creates the stdin fifo if it is not already there, and opens it read-write
|
||||
/// for the process to inherit.
|
||||
///
|
||||
/// Read-write is the whole trick: a fifo opened read-only delivers EOF as soon
|
||||
/// as the last writer closes, so the process would exit the moment this server
|
||||
/// did -- exactly what leaving it running has to prevent. Holding it open for
|
||||
/// writing means the process is its own last writer.
|
||||
fn make_fifo(path: &Path) -> Result<std::fs::File> {
|
||||
if !path.exists() {
|
||||
let c_path = std::ffi::CString::new(path.as_os_str().as_encoded_bytes())
|
||||
.with_context(|| format!("{} is not a usable path", path.display()))?;
|
||||
// SAFETY: a nul-terminated path this call only reads, and a mode with
|
||||
// no bits the kernel can object to. Owner-only, like everything else in
|
||||
// a session directory: this carries what the person typed.
|
||||
let made = unsafe { libc::mkfifo(c_path.as_ptr(), 0o600) };
|
||||
if made != 0 {
|
||||
return Err(std::io::Error::last_os_error())
|
||||
.with_context(|| format!("creating the fifo {}", path.display()));
|
||||
}
|
||||
}
|
||||
std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(path)
|
||||
.with_context(|| format!("opening the fifo {}", path.display()))
|
||||
}
|
||||
|
||||
/// A fresh, empty, owner-only log for one of the process's output streams.
|
||||
fn create_log(path: &Path) -> Result<std::fs::File> {
|
||||
std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(path)
|
||||
.with_context(|| format!("creating {}", path.display()))
|
||||
}
|
||||
|
||||
pub(super) fn read_resume_token(session_dir: &Path) -> Option<String> {
|
||||
let text = std::fs::read_to_string(session_dir.join(RESUME_FILE)).ok()?;
|
||||
serde_json::from_str::<Value>(&text)
|
||||
|
||||
+690
-267
File diff suppressed because it is too large.
Load diff
@@ -1,9 +1,11 @@
|
||||
//! `codex exec --json` lines into the common event model.
|
||||
//! Codex app-server notifications into the common event model.
|
||||
//!
|
||||
//! The CLI promises JSONL but deliberately leaves room for new item kinds. We
|
||||
//! therefore match only the records that have a useful common equivalent and
|
||||
//! ignore the rest; an added Codex item must not make a live session go deaf.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::driver::{Event, SessionStatus};
|
||||
@@ -13,11 +15,16 @@ pub(super) struct Translator {
|
||||
pub(super) thread_id: Option<String>,
|
||||
completed: bool,
|
||||
limited: bool,
|
||||
streamed_messages: HashSet<String>,
|
||||
pending_usage: Option<u64>,
|
||||
}
|
||||
|
||||
impl Translator {
|
||||
pub(super) fn translate(&mut self, line: &Value) -> Vec<Event> {
|
||||
match line.get("type").and_then(Value::as_str) {
|
||||
let method = line.get("method").and_then(Value::as_str);
|
||||
let body = method.and_then(|_| line.get("params")).unwrap_or(line);
|
||||
let kind = method.or_else(|| line.get("type").and_then(Value::as_str));
|
||||
match kind {
|
||||
Some("thread.started") => {
|
||||
self.thread_id = line
|
||||
.get("thread_id")
|
||||
@@ -25,16 +32,65 @@ impl Translator {
|
||||
.map(str::to_string);
|
||||
Vec::new()
|
||||
}
|
||||
Some("turn.started") => vec![Event::Status {
|
||||
state: SessionStatus::Running,
|
||||
}],
|
||||
Some("item.started") => start_item(&line["item"]),
|
||||
Some("thread/started") => {
|
||||
self.thread_id = body
|
||||
.pointer("/thread/id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string);
|
||||
Vec::new()
|
||||
}
|
||||
Some("turn.started") | Some("turn/started") => {
|
||||
self.completed = false;
|
||||
self.limited = false;
|
||||
self.pending_usage = None;
|
||||
self.streamed_messages.clear();
|
||||
vec![Event::Status {
|
||||
state: SessionStatus::Running,
|
||||
}]
|
||||
}
|
||||
Some("item.started") | Some("item/started") => start_item(&body["item"]),
|
||||
Some("item.updated") => update_item(&line["item"]),
|
||||
Some("item.completed") => complete_item(&line["item"]),
|
||||
Some("turn.completed") => {
|
||||
Some("item.completed") | Some("item/completed") => {
|
||||
complete_item(&body["item"], &self.streamed_messages)
|
||||
}
|
||||
Some("item/agentMessage/delta") => {
|
||||
let Some(delta) = body.get("delta").and_then(Value::as_str) else {
|
||||
return Vec::new();
|
||||
};
|
||||
if let Some(id) = body.get("itemId").and_then(Value::as_str) {
|
||||
self.streamed_messages.insert(id.to_string());
|
||||
}
|
||||
vec![Event::AssistantText {
|
||||
delta: delta.to_string(),
|
||||
}]
|
||||
}
|
||||
Some("item/commandExecution/outputDelta") => {
|
||||
let (Some(id), Some(output)) = (
|
||||
body.get("itemId").and_then(Value::as_str),
|
||||
body.get("delta").and_then(Value::as_str),
|
||||
) else {
|
||||
return Vec::new();
|
||||
};
|
||||
vec![Event::ToolUpdate {
|
||||
id: id.to_string(),
|
||||
output: output.to_string(),
|
||||
}]
|
||||
}
|
||||
Some("thread/tokenUsage/updated") => {
|
||||
self.pending_usage = body
|
||||
.pointer("/tokenUsage/last/totalTokens")
|
||||
.and_then(Value::as_u64);
|
||||
Vec::new()
|
||||
}
|
||||
Some("turn.completed") | Some("turn/completed") => {
|
||||
self.completed = true;
|
||||
let mut events = Vec::new();
|
||||
if let Some(usage) = line.get("usage") {
|
||||
if let Some(tokens) = self.pending_usage.take() {
|
||||
events.push(Event::UsageDelta {
|
||||
tokens,
|
||||
context: None,
|
||||
});
|
||||
} else if let Some(usage) = line.get("usage") {
|
||||
let input = number(usage, "input_tokens");
|
||||
let output = number(usage, "output_tokens");
|
||||
if input.is_some() || output.is_some() {
|
||||
@@ -46,20 +102,27 @@ impl Translator {
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(error) = body.pointer("/turn/error")
|
||||
&& !error.is_null()
|
||||
{
|
||||
events.extend(self.failure(error));
|
||||
}
|
||||
events.push(Event::Status {
|
||||
state: SessionStatus::Idle,
|
||||
});
|
||||
events
|
||||
}
|
||||
Some("turn.failed") | Some("error") => self.failure(line),
|
||||
Some("turn.failed") | Some("error") => self.failure(body),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn completed(&self) -> bool {
|
||||
self.completed
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn limited(&self) -> bool {
|
||||
self.limited
|
||||
}
|
||||
@@ -106,19 +169,24 @@ fn update_item(item: &Value) -> Vec<Event> {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn complete_item(item: &Value) -> Vec<Event> {
|
||||
fn complete_item(item: &Value, streamed_messages: &HashSet<String>) -> Vec<Event> {
|
||||
match item.get("type").and_then(Value::as_str) {
|
||||
Some("agent_message") => item
|
||||
Some("agent_message" | "agentMessage") => item
|
||||
.get("text")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|text| !text.is_empty())
|
||||
.filter(|_| {
|
||||
item.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.is_none_or(|id| !streamed_messages.contains(id))
|
||||
})
|
||||
.map(|delta| {
|
||||
vec![Event::AssistantText {
|
||||
delta: delta.to_string(),
|
||||
}]
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
Some("reasoning") => Vec::new(),
|
||||
Some("reasoning" | "userMessage") => Vec::new(),
|
||||
_ => {
|
||||
let Some((id, _, _)) = tool(item) else {
|
||||
return Vec::new();
|
||||
@@ -133,15 +201,15 @@ fn tool(item: &Value) -> Option<(String, String, Value)> {
|
||||
let id = item.get("id")?.as_str()?.to_string();
|
||||
let kind = item.get("type")?.as_str()?;
|
||||
let (name, input) = match kind {
|
||||
"command_execution" => (
|
||||
"command_execution" | "commandExecution" => (
|
||||
"exec_command".to_string(),
|
||||
json!({"command": item.get("command").cloned().unwrap_or(Value::Null)}),
|
||||
),
|
||||
"file_change" => (
|
||||
"file_change" | "fileChange" => (
|
||||
"apply_patch".to_string(),
|
||||
item.get("changes").cloned().unwrap_or(Value::Null),
|
||||
),
|
||||
"mcp_tool_call" => (
|
||||
"mcp_tool_call" | "mcpToolCall" => (
|
||||
item.get("tool")
|
||||
.or_else(|| item.get("name"))
|
||||
.and_then(Value::as_str)
|
||||
@@ -149,18 +217,24 @@ fn tool(item: &Value) -> Option<(String, String, Value)> {
|
||||
.unwrap_or_else(|| "mcp".to_string()),
|
||||
item.get("arguments").cloned().unwrap_or(Value::Null),
|
||||
),
|
||||
"web_search" => (
|
||||
"web_search" | "webSearch" => (
|
||||
"web_search".to_string(),
|
||||
json!({"query": item.get("query").cloned().unwrap_or(Value::Null)}),
|
||||
),
|
||||
"todo_list" => ("update_plan".to_string(), item.clone()),
|
||||
"todo_list" | "todoList" | "plan" => ("update_plan".to_string(), item.clone()),
|
||||
_ => return None,
|
||||
};
|
||||
Some((id, name, input))
|
||||
}
|
||||
|
||||
fn tool_output(item: &Value) -> String {
|
||||
for key in ["aggregated_output", "output", "result", "error"] {
|
||||
for key in [
|
||||
"aggregated_output",
|
||||
"aggregatedOutput",
|
||||
"output",
|
||||
"result",
|
||||
"error",
|
||||
] {
|
||||
if let Some(text) = item.get(key).and_then(value_text)
|
||||
&& !text.is_empty()
|
||||
{
|
||||
@@ -276,4 +350,53 @@ mod tests {
|
||||
);
|
||||
assert!(translator.limited());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translates_native_app_server_streaming_without_repeating_the_final_item() {
|
||||
let mut translator = Translator::default();
|
||||
assert_eq!(
|
||||
translator.translate(&line(
|
||||
r#"{"method":"turn/started","params":{"threadId":"thread-1","turn":{"id":"turn-1"}}}"#
|
||||
)),
|
||||
vec![Event::Status {
|
||||
state: SessionStatus::Running
|
||||
}]
|
||||
);
|
||||
assert_eq!(
|
||||
translator.translate(&line(
|
||||
r#"{"method":"item/agentMessage/delta","params":{"itemId":"message-1","delta":"hello"}}"#
|
||||
)),
|
||||
vec![Event::AssistantText {
|
||||
delta: "hello".to_string()
|
||||
}]
|
||||
);
|
||||
assert!(
|
||||
translator
|
||||
.translate(&line(
|
||||
r#"{"method":"item/completed","params":{"item":{"id":"message-1","type":"agentMessage","text":"hello"}}}"#
|
||||
))
|
||||
.is_empty()
|
||||
);
|
||||
assert!(
|
||||
translator
|
||||
.translate(&line(
|
||||
r#"{"method":"thread/tokenUsage/updated","params":{"tokenUsage":{"last":{"totalTokens":42}}}}"#
|
||||
))
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(
|
||||
translator.translate(&line(
|
||||
r#"{"method":"turn/completed","params":{"turn":{"id":"turn-1","status":"completed","error":null}}}"#
|
||||
)),
|
||||
vec![
|
||||
Event::UsageDelta {
|
||||
tokens: 42,
|
||||
context: None
|
||||
},
|
||||
Event::Status {
|
||||
state: SessionStatus::Idle
|
||||
}
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
+71
-14
@@ -205,6 +205,9 @@ pub struct SessionInfo {
|
||||
/// Reported rather than worked out on the phone, because the phone has
|
||||
/// the provider's *name* and this is a property of its *kind*.
|
||||
pub keeps_own_transcript: bool,
|
||||
/// The CLI whose copy the delete dialog can optionally remove.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub own_transcript_name: Option<&'static str>,
|
||||
/// How much this session asks before acting. Reported so the phone can
|
||||
/// *show* the current mode rather than assume one -- a picker that
|
||||
/// guesses its own value is how you change something you thought you
|
||||
@@ -600,6 +603,7 @@ impl LiveSession {
|
||||
usage_provider: kind.and_then(DriverKind::usage_provider),
|
||||
imported,
|
||||
keeps_own_transcript: kind.is_some_and(DriverKind::keeps_own_transcript),
|
||||
own_transcript_name: kind.and_then(DriverKind::own_transcript_name),
|
||||
cwd: cwd.map(Path::to_path_buf),
|
||||
status: *self.shared.status.lock().unwrap(),
|
||||
last_activity: *self.shared.last_activity.lock().unwrap(),
|
||||
@@ -625,9 +629,8 @@ pub struct SessionManager {
|
||||
/// Where every session's pump reports what this layer does not act on --
|
||||
/// see [`Announcements`].
|
||||
announce: Announcements,
|
||||
/// Imports and deletes running against a machine's Claude Code
|
||||
/// sessions: like the notifications, state the phone reads but does not
|
||||
/// own.
|
||||
/// Provider-transcript operations running against a machine: like the
|
||||
/// notifications, state the phone reads but does not own.
|
||||
pending: Arc<pending::Registry>,
|
||||
/// What to mark sessions spawned here as -- see
|
||||
/// [`SessionManager::marking_new_sessions_throwaway`].
|
||||
@@ -640,6 +643,38 @@ pub struct SessionManager {
|
||||
inner: RwLock<Inner>,
|
||||
}
|
||||
|
||||
/// The CLI-owned copy optionally removed with an ai-app session.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct ForeignTranscript {
|
||||
pub setup: String,
|
||||
pub id: String,
|
||||
kind: DriverKind,
|
||||
}
|
||||
|
||||
impl ForeignTranscript {
|
||||
/// Removes the provider's own durable record. The route remains generic:
|
||||
/// adding another transcript-owning driver extends this provider boundary.
|
||||
pub async fn delete(&self, transport: &Transport) -> Result<()> {
|
||||
match self.kind {
|
||||
DriverKind::ClaudeCli => import::delete(transport, std::slice::from_ref(&self.id))
|
||||
.await?
|
||||
.remove(&self.id)
|
||||
.unwrap_or_else(|| Err(format!("nothing was reported about {}", self.id)))
|
||||
.map_err(anyhow::Error::msg),
|
||||
DriverKind::CodexCli => codex::delete_transcript(transport, &self.id).await,
|
||||
DriverKind::Echo | DriverKind::LlamaCpp => {
|
||||
anyhow::bail!("this provider keeps no transcript of its own")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn owner_name(&self) -> &'static str {
|
||||
self.kind
|
||||
.own_transcript_name()
|
||||
.expect("a foreign transcript has an owner")
|
||||
}
|
||||
}
|
||||
|
||||
impl SessionManager {
|
||||
/// Loads the config and brings every persisted session back: its
|
||||
/// transcript, its pump, and the process it left running where it left
|
||||
@@ -1034,15 +1069,26 @@ impl SessionManager {
|
||||
Some((setup.ssh.clone()?, meta.cwd.clone()))
|
||||
}
|
||||
|
||||
pub fn foreign_transcript(&self, id: &str) -> Option<(String, String)> {
|
||||
pub fn foreign_transcript(&self, id: &str) -> Option<ForeignTranscript> {
|
||||
let inner = self.inner.read().unwrap();
|
||||
let meta = inner.config.sessions.iter().find(|meta| meta.id == id)?;
|
||||
let (followed, resuming) = foreign_ids(&self.data_dir.join(&meta.id));
|
||||
// The cursor first: an imported session follows a file that exists
|
||||
// whether or not a CLI has resumed it yet.
|
||||
followed
|
||||
.or(resuming)
|
||||
.map(|foreign| (meta.setup.clone(), foreign))
|
||||
let kind = kind_of(&inner.config, &meta.setup, &meta.provider)?;
|
||||
let session_dir = self.data_dir.join(&meta.id);
|
||||
let foreign = match kind {
|
||||
DriverKind::ClaudeCli => {
|
||||
let (followed, resuming) = foreign_ids(&session_dir);
|
||||
// The cursor first: an imported session follows a file that exists
|
||||
// whether or not a CLI has resumed it yet.
|
||||
followed.or(resuming)
|
||||
}
|
||||
DriverKind::CodexCli => codex::read_thread(&session_dir),
|
||||
DriverKind::Echo | DriverKind::LlamaCpp => None,
|
||||
}?;
|
||||
Some(ForeignTranscript {
|
||||
setup: meta.setup.clone(),
|
||||
id: foreign,
|
||||
kind,
|
||||
})
|
||||
}
|
||||
|
||||
/// Every session, in config order, with live status joined in. A
|
||||
@@ -1088,6 +1134,8 @@ impl SessionManager {
|
||||
&meta.setup,
|
||||
&meta.provider,
|
||||
),
|
||||
own_transcript_name: kind_of(&inner.config, &meta.setup, &meta.provider)
|
||||
.and_then(DriverKind::own_transcript_name),
|
||||
cwd: meta.cwd.clone(),
|
||||
status: status_of_unlaunched(&self.data_dir.join(&meta.id)),
|
||||
last_activity: meta.created,
|
||||
@@ -2804,6 +2852,8 @@ mod tests {
|
||||
#[test]
|
||||
fn only_a_driver_that_keeps_its_own_record_survives_deletion() {
|
||||
assert!(DriverKind::ClaudeCli.keeps_own_transcript());
|
||||
assert!(DriverKind::CodexCli.keeps_own_transcript());
|
||||
assert_eq!(DriverKind::CodexCli.own_transcript_name(), Some("Codex"));
|
||||
assert!(!DriverKind::Echo.keeps_own_transcript());
|
||||
assert!(!DriverKind::LlamaCpp.keeps_own_transcript());
|
||||
}
|
||||
@@ -3268,14 +3318,17 @@ mod tests {
|
||||
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 provider = seed_stand_in_cli(&config_path, dir.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");
|
||||
.expect("manager")
|
||||
.marking_new_sessions_throwaway(true);
|
||||
let info = manager
|
||||
.spawn_session(stand_in_spec(&provider))
|
||||
.expect("spawn");
|
||||
|
||||
// Nothing recorded yet, so there is nothing a delete would reach.
|
||||
assert_eq!(manager.foreign_transcript(&info.id), None);
|
||||
@@ -3283,7 +3336,11 @@ mod tests {
|
||||
claude::write_resume_token(&data_dir.join(&info.id), "5ecf21da-d53f");
|
||||
assert_eq!(
|
||||
manager.foreign_transcript(&info.id),
|
||||
Some((info.setup.clone(), "5ecf21da-d53f".to_string()))
|
||||
Some(ForeignTranscript {
|
||||
setup: info.setup.clone(),
|
||||
id: "5ecf21da-d53f".to_string(),
|
||||
kind: DriverKind::ClaudeCli,
|
||||
})
|
||||
);
|
||||
|
||||
// A session that is not there has no transcript to name, rather
|
||||
|
||||
@@ -187,6 +187,39 @@ pub fn clear(session_dir: &Path) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a session stdin fifo and opens it read-write for the child.
|
||||
///
|
||||
/// The child holding the write end is what keeps a detached JSON server from
|
||||
/// reading EOF when ai-server restarts and temporarily closes its own writer.
|
||||
pub fn make_fifo(path: &Path) -> Result<std::fs::File> {
|
||||
if !path.exists() {
|
||||
let c_path = std::ffi::CString::new(path.as_os_str().as_encoded_bytes())
|
||||
.with_context(|| format!("{} is not a usable path", path.display()))?;
|
||||
// SAFETY: `c_path` is nul-terminated and this call only reads it.
|
||||
let made = unsafe { libc::mkfifo(c_path.as_ptr(), 0o600) };
|
||||
if made != 0 {
|
||||
return Err(std::io::Error::last_os_error())
|
||||
.with_context(|| format!("creating the fifo {}", path.display()));
|
||||
}
|
||||
}
|
||||
std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(path)
|
||||
.with_context(|| format!("opening the fifo {}", path.display()))
|
||||
}
|
||||
|
||||
/// A fresh owner-only log for a detached session process.
|
||||
pub fn create_log(path: &Path) -> Result<std::fs::File> {
|
||||
std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(path)
|
||||
.with_context(|| format!("creating {}", path.display()))
|
||||
}
|
||||
|
||||
/// Grace period between asking a session's process to stop and killing it.
|
||||
/// Here rather than beside each caller: two drivers plus the manager had
|
||||
/// written the same five seconds down separately.
|
||||
|
||||
Reference in new issue
Block a user