Merge branch 'main' of git.arirex.me:iris/ai-app

This commit is contained in:
iris committed 2026-08-30 13:25:00 -04:00
commit 1ed6e29bc6
14 files changed
+637 -180

No files matched your search

+27 -14
View File
@@ -89,7 +89,11 @@ repo is in PLAN.md's "Backend layout" section.
codepoint in the Kotlin that the script did not subset is a glyph that codepoint in the Kotlin that the script did not subset is a glyph that
silently isn't there. Rerun the script and commit its output when adding silently isn't there. Rerun the script and commit its output when adding
one; it needs network access. `md-cog` and `md-refresh` are deliberately one; it needs network access. `md-cog` and `md-refresh` are deliberately
the same codepoints dev-updater uses and must not drift from it. the same codepoints dev-updater uses and must not drift from it. The
subset is the **Mono** face, where every glyph is one em square — that is
what makes two icon buttons the same width without either being given
one, and it is why `GLYPH_SIZE` is smaller than it looks like it should
be.
- `.dev-updater.ron` — what Dev Updater is asked to do with this checkout: - `.dev-updater.ron` — what Dev Updater is asked to do with this checkout:
the server (built in `server/`, run as `service: Managed(...)`) and the the server (built in `server/`, run as `service: Managed(...)`) and the
APK (built in `app/`), built in parallel. The project it serves is the APK (built in `app/`), built in parallel. The project it serves is the
@@ -221,16 +225,19 @@ first if a remote spawn ever mangles an argument.
The emulator app reaches it at `https://10.0.2.2:8443`; enroll it with The emulator app reaches it at `https://10.0.2.2:8443`; enroll it with
`adb -s "$SERIAL" shell "am start -a android.intent.action.VIEW -d 'aiapp://enroll?host=10.0.2.2&port=8443&token=…'"` `adb -s "$SERIAL" shell "am start -a android.intent.action.VIEW -d 'aiapp://enroll?host=10.0.2.2&port=8443&token=…'"`
(quote so the device shell doesn't eat the `&`s). (quote so the device shell doesn't eat the `&`s).
- **Name the device on every `adb` call.** This checkout runs its own - **The emulator is `~/repos/emulator-tools`' business, not this repo's.**
emulator — `run-android.sh` derives the AVD name from the directory, so `emu up` creates and boots the AVD named after this checkout (`ai-app-2`),
a second clone gets a second AVD rather than queueing for one shared refusing when the machine has no room for one; `emu list` says what is
machine-wide `tdep`. With more than one attached, a bare `adb shell` or attached and what it costs; `emu down` stops it. `run-android.sh` is that
`adb get-state` fails with `more than one device/emulator` and a bare plus a build and an install. Run that repo's `install.sh` once if `emu` is
`adb shell pm list packages` comes back **empty**, which reads as the app missing.
having been uninstalled rather than as the question being ambiguous. The `adb` on `PATH` after sourcing `android-env.sh` is that repo's wrapper,
Get the serial the way `run-android.sh` does — match `adb -s <serial> emu which fills in `-s` from the same rule — so a bare `adb shell` reaches this
avd name` against the AVD — or export `ANDROID_SERIAL`, which every checkout's emulator and refuses to reach another one's. That defaulting is
`adb` call honours without `-s`. what makes the old advice unnecessary rather than wrong: with two attached
and no `-s`, a bare `adb shell pm list packages` comes back **empty**,
which reads as the app having been uninstalled rather than as the question
being ambiguous.
## Where things run (host vs this VM) ## Where things run (host vs this VM)
@@ -280,15 +287,21 @@ day:
- **Stopping the server no longer stops the sessions.** After `pkill - **Stopping the server no longer stops the sessions.** After `pkill
ai-server` the `claude` processes are still there, on purpose, and the ai-server` the `claude` processes are still there, on purpose, and the
next start picks them up (`reattaching to the claude-cli it left next start picks them up (`reattaching to the claude-cli it left
running` in the log). To actually end one, delete the session — that is running` in the log). To end one, either `POST /sessions/{id}/stop` —
the only path that stops a process. which keeps the session and its transcript, and `POST .../start` brings
the process back on the same conversation — or delete the session, which
ends the conversation too.
- **Each session directory now holds `process.json`, `stdin.fifo`, - **Each session directory now holds `process.json`, `stdin.fifo`,
`stdout.log` and `stderr.log`.** `stdout.log` is the driver's input, read `stdout.log` and `stderr.log`.** `stdout.log` is the driver's input, read
from the byte offset in `process.json`; removing either by hand while the from the byte offset in `process.json`; removing either by hand while the
session is live loses output or replays it. session is live loses output or replays it.
- **`--resume` only ever runs when nothing is running.** That check is the - **`--resume` only ever runs when nothing is running.** That check is the
fix for the incident below, and the reason there is one entry point fix for the incident below, and the reason there is one entry point
(`ClaudeDriver::launch`) rather than a spawn and an attach. (`ClaudeDriver::launch`) rather than a spawn and an attach. The status a
launch reports obeys the same rule: a session recorded as `exited` whose
launch has just started a process reports `idle`, because `exited` is the
word that refuses every command and offers a phone the chance to start a
second CLI on a live conversation.
- Remote sessions are adopted too. The pid recorded for one is the **`ssh` - Remote sessions are adopted too. The pid recorded for one is the **`ssh`
client's**, on this machine — that is the process the backend owns, and it client's**, on this machine — that is the process the backend owns, and it
lives as long as the remote command does. (This said "local only" until lives as long as the remote command does. (This said "local only" until
+62 -1
View File
@@ -298,6 +298,65 @@ Two consequences worth stating:
going away and means to come back) and `stop` (the session is being deleted, going away and means to come back) and `stop` (the session is being deleted,
so the process must not survive). Every driver owes exactly one of them. so the process must not survive). Every driver owes exactly one of them.
### Stopping and starting a session's process (decided 2026-08-30)
If a session outlives the backend, the person holding the phone needs the
other direction too: **end the process without ending the session, and start
it again on the same conversation.** `POST /sessions/:id/stop` and
`/start`.
Three decisions worth not undoing:
- **Stop signals the recorded process and says nothing else.** It does not
go through the driver and it does not announce `Exited`. The record is the
session's rather than any dialect's, so signalling it here works for a
session whose driver is in no state to be asked and adds no trait method a
new driver could implement wrongly — and the driver's own reader already
reports the death correctly, draining the last output and recording the
status. Announcing it from here would be a guess arriving ahead of the
measurement, and wrong for the grace period a process that ignores SIGTERM
keeps running.
- **Start replaces the driver and nothing else.** The transcript, the event
pump and the SSE stream every open phone is reading stay where they were,
so starting a session again is not a reconnect for anybody watching, and
there is still exactly one writer of the transcript — which relaunching
the whole `LiveSession` would not be, since the old pump outlives its
session and an imported session's sync task would go on feeding it.
`LiveSession` and `Commands` therefore share one `Mutex<Arc<dyn Driver>>`
rather than each holding a copy.
- **Start is refused unless the session is *known* to have exited.**
`Unknown` means nobody could find out whether the process is alive, and
starting one on that is exactly the two-CLIs-on-one-conversation fault
`session::process` exists to prevent.
That last rule found a real bug in the launch path, which is where the phone
would have hit it: a relaunched session took its status from the transcript,
so one whose process had died before a backend restart reported `Exited`
while the launch it had just gone through was starting a new process —
`Exited` there is not merely stale, it is the word that refuses every command
and invites somebody to start a second process against a live conversation.
**Who says so matters as much as what is said.** The first fix wrote `Idle`
straight into the manager's view, and that produced a second bug on the
phone: the session list reads the manager's status and the session screen
replays the transcript, so a status written in one and not the other is two
screens disagreeing about one session — visible as a stop button that turned
into a play button a moment after the screen opened. So the rule is that
**a driver announces the state it starts in, through the event sink**, which
is what `EchoDriver::new` and `LlamaDriver::attached` already did;
`ClaudeDriver` was the one that started a process silently. It says `Idle`
only when it *started* one — adopting says nothing, because a process that
was already running may be mid-turn and the transcript's last word is the
better answer until its output says otherwise. Coming from the driver also
orders it against the exit `follow` reports, which a status written from the
manager could not be.
On the phone this is one button in the composer, left of Send, whose mark and
colour say what pressing it would do now: an orange pause while a turn is
running (interrupt — the process stays), a red stop when it is not (end the
process), and a green play when it has exited (start it again). One button
rather than three that come and go, so its presence is never the signal.
### Importing refuses a session that is already open (decided 2026-08-29) ### Importing refuses a session that is already open (decided 2026-08-29)
Claude Code keeps a descriptor per live session at Claude Code keeps a descriptor per live session at
@@ -448,7 +507,9 @@ POST /sessions spawn {provider, host, model, cwd, permiss
GET /sessions/:id/events?after=N SSE: transcript replay from N, then live GET /sessions/:id/events?after=N SSE: transcript replay from N, then live
POST /sessions/:id/message {text, attachment_ids} POST /sessions/:id/message {text, attachment_ids}
POST /sessions/:id/answer {question_id, answer} (questions and permissions) POST /sessions/:id/answer {question_id, answer} (questions and permissions)
POST /sessions/:id/interrupt POST /sessions/:id/interrupt stop the running turn; the process stays
POST /sessions/:id/stop end the process; the session and transcript stay
POST /sessions/:id/start run the process again, continuing the conversation
POST /sessions/:id/model {model} POST /sessions/:id/model {model}
POST /sessions/:id/compact (llama sessions) POST /sessions/:id/compact (llama sessions)
POST /sessions/:id/attachments multipart upload → id (referenced by /message) POST /sessions/:id/attachments multipart upload → id (referenced by /message)
@@ -558,6 +558,22 @@ fun interruptSession(settings: ServerSettings, sessionId: String) {
requestFromServer(settings, "/sessions/$sessionId/interrupt", method = "POST") {} requestFromServer(settings, "/sessions/$sessionId/interrupt", method = "POST") {}
} }
/**
* Ends the process behind a session, leaving the session and its transcript.
*
* Not a delete and not an interrupt: the conversation stays exactly where it is and [startSession]
* picks it back up. The server reports what it could not do -- there was nothing running, or the
* machine would not say whether there was -- rather than answering the same way either way.
*/
fun stopSession(settings: ServerSettings, sessionId: String) {
requestFromServer(settings, "/sessions/$sessionId/stop", method = "POST") {}
}
/** Starts the process again on the conversation it left. See [stopSession]. */
fun startSession(settings: ServerSettings, sessionId: String) {
requestFromServer(settings, "/sessions/$sessionId/start", method = "POST") {}
}
/** /**
* Removes a Claude Code session from the machine. * Removes a Claude Code session from the machine.
* *
@@ -31,6 +31,12 @@ import androidx.compose.ui.unit.sp
* tofu. Adding one means adding its codepoint in *both* places; a codepoint here that the script * tofu. Adding one means adding its codepoint in *both* places; a codepoint here that the script
* did not subset is a glyph that silently isn't there. * did not subset is a glyph that silently isn't there.
* *
* The subset is the font's **Mono** face, where every glyph is exactly one em wide and one em tall.
* That is what makes two icons the same size without either of them being given a size: the
* proportional face's advances run from 0.46 em to 0.92 em, so a Send button and a Stop button side
* by side came out visibly different widths, and matching them at the call site would have meant
* one hardcoded measurement per pair. [GLYPH_SIZE] carries the cost.
*
* The same arrangement as dev-updater, down to the cog and the refresh arrow being the same two * The same arrangement as dev-updater, down to the cog and the refresh arrow being the same two
* Material Design codepoints. Those two must not drift: an icon that means "settings" in one app * Material Design codepoints. Those two must not drift: an icon that means "settings" in one app
* and something else in the other is the failure this is worth preventing. The script is copied * and something else in the other is the failure this is worth preventing. The script is copied
@@ -52,9 +58,27 @@ val REFRESH_GLYPH = glyph(0xF0450)
/** `md-send` -- the filled paper plane: submit what is in the composer. */ /** `md-send` -- the filled paper plane: submit what is in the composer. */
val SEND_GLYPH = glyph(0xF048A) val SEND_GLYPH = glyph(0xF048A)
/** `md-stop` -- a filled square: interrupt the turn that is running. */ /**
* `md-stop` -- a filled square: end the process behind this session.
*
* The square is what stop has meant since tape decks, and it is spent here on the thing that
* actually stops rather than on pausing. [PAUSE_GLYPH] is the turn; this is the session.
*/
val STOP_GLYPH = glyph(0xF04DB) val STOP_GLYPH = glyph(0xF04DB)
/**
* `md-pause` -- two bars: take the running turn away and leave the session there.
*
* The pair with [STOP_GLYPH] and [PLAY_GLYPH] is the point: one button in the composer says what
* pressing it now would do to the process, and the three marks are the three answers. An interrupt
* ends a turn and nothing else -- the CLI is still there and still holds the conversation -- which
* is a pause, not a stop, and drawing it as a square said otherwise.
*/
val PAUSE_GLYPH = glyph(0xF03E4)
/** `md-play` -- start the process again, on the conversation it left. See [PAUSE_GLYPH]. */
val PLAY_GLYPH = glyph(0xF040A)
/** /**
* `md-send_clock` -- the same paper plane with a clock on it: this message will wait its turn. * `md-send_clock` -- the same paper plane with a clock on it: this message will wait its turn.
* *
@@ -142,5 +166,13 @@ fun Glyph(
Text(glyph, fontFamily = NerdIcons, fontSize = size, color = colour, modifier = modifier) Text(glyph, fontFamily = NerdIcons, fontSize = size, color = colour, modifier = modifier)
} }
/** The size an icon draws at beside a line of text. */ /**
private val GLYPH_SIZE = 20.sp * The size an icon draws at beside a line of text.
*
* 17 rather than the 20 it was while the font was the proportional face. A glyph there filled at
* most 0.83 em of its point size and most filled a good deal less, so the number was standing in
* for the headroom above the tallest one; in the Mono face every glyph fills its em exactly, and
* keeping 20 would have made every icon in the app step up by a fifth for no reason anybody asked
* for. This is what the largest of them already drew at.
*/
private val GLYPH_SIZE = 17.sp
@@ -1478,30 +1478,44 @@ fun SessionScreen(
}, },
) )
} }
if (running) { // The same filled shape as the button beside it, not an outlined one: these are
// The same filled shape as the button beside it, not an outlined one: these // two things you can do about the session, and weighting one of them as secondary
// are two things you can do about the turn that is running, and weighting one // said they were a primary action and its qualifier. What separates them is the
// of them as secondary said they were a primary action and its qualifier. // colour and the mark, which is what they mean.
// What separates them is the colour and the mark, which is what they mean. //
Button( // Always here, rather than arriving with the turn as it used to. A control that
onClick = { act { interruptSession(settings, summary.id) } }, // comes and goes makes its own presence the signal, and its absence could not say
colors = actionButtonColors(stopColor), // whether there was nothing to do; a button that is always in the same place also
) { // cannot push Send off the end of the row by turning up.
// A filled square, which is what stop has looked like since tape decks. val process =
Glyph( when {
STOP_GLYPH, running -> ProcessAction.Pause
colour = LocalContentColor.current, status == "exited" -> ProcessAction.Start
modifier = Modifier.semantics { contentDescription = "Stop" }, else -> ProcessAction.Stop
)
} }
Spacer(Modifier.width(8.dp)) Button(
onClick = { act { process.perform(settings, summary.id) } },
colors = actionButtonColors(process.colour()),
) {
Glyph(
process.glyph,
colour = LocalContentColor.current,
modifier = Modifier.semantics { contentDescription = process.label },
)
} }
Spacer(Modifier.width(8.dp))
// The paper plane, with a clock on it while a turn is in flight: sending then // The paper plane, with a clock on it while a turn is in flight: sending then
// queues the message for the next tool boundary rather than starting a turn of // queues the message for the next tool boundary rather than starting a turn of
// its own, and the two have to be told apart at a glance. The label says the same // its own, and the two have to be told apart at a glance. The label says the same
// thing to a screen reader, which has nothing else to read. // thing to a screen reader, which has nothing else to read.
//
// Disabled while there is nothing to send, rather than pressable and silent:
// `send` has always returned early on an empty composer, so the button promised
// something it would not do, and the only feedback was the ripple. Disabled and
// not hidden, for the reason the button beside it is always here.
Button( Button(
onClick = { send() }, onClick = { send() },
enabled = input.isNotBlank() || pendingAttachments.isNotEmpty(),
colors = actionButtonColors(if (running) queueColor else sendColor), colors = actionButtonColors(if (running) queueColor else sendColor),
) { ) {
Glyph( Glyph(
@@ -1522,6 +1536,39 @@ fun SessionScreen(
/** What pressing Send does right now, said the same way to the eye and to a screen reader. */ /** What pressing Send does right now, said the same way to the eye and to a screen reader. */
private fun sendLabel(running: Boolean) = if (running) "Queue" else "Send" private fun sendLabel(running: Boolean) = if (running) "Queue" else "Send"
/**
* What the composer's process button would do if it were pressed now.
*
* One value rather than four parallel conditions over the status, because the mark, the colour, the
* name a screen reader is given and the request that goes out are four halves of one decision. A
* button drawn as a pause that terminates the CLI is the worst bug available here, and separate
* branches over the same condition are how that happens -- these three each have to cover every
* case, and the compiler says so.
*/
private enum class ProcessAction(val glyph: String, val label: String) {
/** A turn is running: take it back, and leave the process holding the conversation. */
Pause(PAUSE_GLYPH, "Pause"),
/** Nothing is running, but the process behind the session is: end it. */
Stop(STOP_GLYPH, "Stop"),
/** The process is gone: start it again, on the conversation it left. */
Start(PLAY_GLYPH, "Start"),
}
@Composable
private fun ProcessAction.colour() =
when (this) {
ProcessAction.Pause -> pauseColor
ProcessAction.Stop -> stopColor
ProcessAction.Start -> startColor
}
private fun ProcessAction.perform(settings: ServerSettings, sessionId: String) =
when (this) {
ProcessAction.Pause -> interruptSession(settings, sessionId)
ProcessAction.Stop -> stopSession(settings, sessionId)
ProcessAction.Start -> startSession(settings, sessionId)
}
/** /**
* An inline transcript image, fetched (authenticated, pinned) from the session's files route. The * An inline transcript image, fetched (authenticated, pinned) from the session's files route. The
* bitmap is remembered per ref, so scrolling doesn't refetch. * bitmap is remembered per ref, so scrolling doesn't refetch.
@@ -213,13 +213,14 @@ val overLimitColor: Color
@Composable get() = MaterialTheme.colorScheme.error @Composable get() = MaterialTheme.colorScheme.error
/** /**
* The composer's three buttons, coloured by what pressing one does rather than by where it sits. * The composer's buttons, coloured by what pressing one does rather than by where it sits.
* *
* Green sends now, blue sends later, red takes the running turn away. The pair of greens and the * Green makes something happen now, blue makes it happen later, orange takes back what is in
* pair of reds elsewhere in this file are deliberate near-collisions worth naming: [runningColor] * flight, red ends the process. The near-collisions with the states above are deliberate and worth
* is green because a session is working, and [failedColor] is red because one fell over -- those * naming rather than collapsing: [runningColor] is green because a session is working,
* are *states*, and these are *actions*. A reader never has to tell them apart, because nothing * [failedColor] is red because one fell over, [awaitingColor] is the same orange because a session
* here is a state and nothing there is pressable. * is waiting on somebody -- those are *states*, and these are *actions*. A reader never has to tell
* them apart, because nothing here is a state and nothing there is pressable.
*/ */
val sendColor: Color val sendColor: Color
@Composable get() = Mocha.Green @Composable get() = Mocha.Green
@@ -228,10 +229,30 @@ val sendColor: Color
val queueColor: Color val queueColor: Color
@Composable get() = Mocha.Blue @Composable get() = Mocha.Blue
/** Interrupting the running turn -- the one button here that takes something away. */ /**
* Interrupting the running turn: the work stops and the session stays.
*
* Orange rather than red because of how much it takes: only what is in flight. The process is still
* there holding the conversation, and the next message starts a turn as though nothing had
* happened. Red is spent on [stopColor], which is the same button in the same place when what it
* would end is the session's process.
*/
val pauseColor: Color
@Composable get() = Mocha.Peach
/** Ending the session's process -- the one button here that takes something away. */
val stopColor: Color val stopColor: Color
@Composable get() = Mocha.Red @Composable get() = Mocha.Red
/**
* Starting the process again, on the conversation it left.
*
* The same green as [sendColor] on purpose: both mean "this happens now", and they are never the
* same button -- the process button only offers to start when there is nothing running to stop.
*/
val startColor: Color
@Composable get() = Mocha.Green
/** /**
* A filled button in one of the action colours above. * A filled button in one of the action colours above.
* *
Binary file not shown.
+16 -3
View File
@@ -32,6 +32,8 @@ GLYPHS=(
U+F0450 # md-refresh U+F0450 # md-refresh
U+F048A # md-send U+F048A # md-send
U+F04DB # md-stop U+F04DB # md-stop
U+F03E4 # md-pause
U+F040A # md-play
U+F1163 # md-send_clock U+F1163 # md-send_clock
U+F0156 # md-close U+F0156 # md-close
U+F004D # md-arrow_left U+F004D # md-arrow_left
@@ -52,9 +54,20 @@ python3 -m venv "$work/venv"
unicodes="$(IFS=,; echo "${GLYPHS[*]}")" unicodes="$(IFS=,; echo "${GLYPHS[*]}")"
mkdir -p "$(dirname "$out")" mkdir -p "$(dirname "$out")"
# The proportional face rather than the Mono one: these are drawn inline # The Mono face rather than the proportional one, which this used until
# beside text, where a fixed advance would pad each icon out to a cell. # 2026-08-30. Every glyph in it is one em wide and one em tall, so two
"$work/venv/bin/pyftsubset" "$work/SymbolsNerdFont-Regular.ttf" \ # icons drawn at the same size are the same size -- which is what makes two
# icon buttons beside each other match without either of them being told a
# width. In the proportional face the advances run from 0.46 em (play) to
# 0.92 em (line chart), so the composer's Send button came out visibly wider
# than the Stop button next to it, and any fix at the call site would have
# been one measurement hardcoded per pair.
#
# The trade is the one the old comment named: an icon inline beside text is
# padded out to a cell. That is worth it, and it is also why GLYPH_SIZE in
# NerdIcons.kt came down when this changed -- a glyph that fills its em
# draws bigger at the same point size than one that does not.
"$work/venv/bin/pyftsubset" "$work/SymbolsNerdFontMono-Regular.ttf" \
--unicodes="$unicodes" \ --unicodes="$unicodes" \
--layout-features= \ --layout-features= \
--drop-tables+=DSIG \ --drop-tables+=DSIG \
+26 -91
View File
@@ -1,7 +1,13 @@
#!/bin/sh #!/bin/sh
# Builds and runs this app on an emulator, creating/booting the AVD first if # Builds this app and runs it on this checkout's emulator.
# it isn't already up. Same flow as dev-updater's run-android.sh; see that #
# script for the reasoning behind the avd handling. # The emulator half of this -- which AVD this checkout means, creating it,
# booting it headless, and refusing to start one the machine has no room for
# -- lives in ~/repos/emulator-tools and is shared with every other Android
# checkout here. This script kept its own copy of that sequence until
# 2026-08-30, as did dev-updater's and ai-app's, and three copies of "boot an
# emulator" is three places for the memory check that was missing from all of
# them.
# #
# Environment setup (SDK location, PATH, ...) lives in ./android-env.sh, # Environment setup (SDK location, PATH, ...) lives in ./android-env.sh,
# which can also be sourced directly for one-off commands. # which can also be sourced directly for one-off commands.
@@ -9,105 +15,34 @@ set -eu
APP_ID="com.example.aiapp" APP_ID="com.example.aiapp"
DEVICE_PROFILE="${DEVICE_PROFILE:-pixel_10}"
SYSTEM_IMAGE="${SYSTEM_IMAGE:-system-images;android-36;google_apis;x86_64}"
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
cd "$SCRIPT_DIR" cd "$SCRIPT_DIR"
# One AVD per checkout, named after it -- so two clones of this repo, or a
# clone and a worktree, each get their own rather than fighting over one.
# This used to default to a machine-wide "tdep", which made the emulator the
# one thing here that could not be worked on in parallel: taking it meant
# asking whoever had it, waiting, and handing it back, and installing onto a
# running one steals the foreground from whatever they were looking at.
# Derived rather than written down, so neither clone names the other's.
# Override with AVD_NAME=... to share one deliberately.
AVD_NAME="${AVD_NAME:-$(basename "$(dirname "$SCRIPT_DIR")")}"
# shellcheck source=./android-env.sh # shellcheck source=./android-env.sh
. ./android-env.sh . ./android-env.sh
# Prints the adb serial of a running instance of AVD "$1", or nothing. if ! command -v emu >/dev/null 2>&1; then
avd_serial() { echo "run-android.sh: no 'emu' command." >&2
for s in $(adb devices | awk '$2 == "device" {print $1}'); do echo " It comes from ~/repos/emulator-tools; run that repo's ./install.sh." >&2
if [ "$(adb -s "$s" emu avd name 2>/dev/null | head -n1 | tr -d '\r')" = "$1" ]; then exit 127
echo "$s"
return 0
fi
done
}
echo "==> Ensuring emulator system image is installed"
android sdk install emulator "$SYSTEM_IMAGE" || echo " (non-fatal: see above)"
if [ ! -f "$ANDROID_AVD_HOME/$AVD_NAME.ini" ]; then
echo "==> Creating AVD '$AVD_NAME' ($DEVICE_PROFILE, $SYSTEM_IMAGE)"
echo no | avdmanager create avd \
-n "$AVD_NAME" \
-k "$SYSTEM_IMAGE" \
--device "$DEVICE_PROFILE" \
--sdcard 512M
else
echo "==> Reusing existing AVD '$AVD_NAME'"
fi fi
# Host keyboard into the emulator -- this app has text fields. # Prints the serial, having created and booted the AVD if it had to. Named
CONFIG_INI="$ANDROID_AVD_HOME/$AVD_NAME.avd/config.ini" # after the checkout, so this cannot land on another session's emulator --
if [ -f "$CONFIG_INI" ]; then # and refuses rather than starting one when the machine is short of memory,
grep -v '^hw\.keyboard=' "$CONFIG_INI" >"$CONFIG_INI.tmp" # because what an OOM kills is somebody else's work rather than the emulator
echo "hw.keyboard=yes" >>"$CONFIG_INI.tmp" # that asked for the memory.
mv "$CONFIG_INI.tmp" "$CONFIG_INI" echo "==> Emulator"
fi SERIAL=$(emu up)
export ANDROID_SERIAL="$SERIAL"
SERIAL=$(avd_serial "$AVD_NAME")
if [ -n "$SERIAL" ]; then
echo "==> Emulator '$AVD_NAME' already running ($SERIAL)"
else
# Clean up a stray/crashed process for this AVD, if any. The bracketed
# first character keeps the pattern from matching the shell running
# this script -- unbracketed, this kills that shell mid-run.
pkill -f "[e]mulator.*-avd $AVD_NAME" >/dev/null 2>&1 || true
EMU_LOG="/tmp/$AVD_NAME-emulator.log"
: >"$EMU_LOG"
if [ -n "${DISPLAY:-}" ] || [ -n "${WAYLAND_DISPLAY:-}" ]; then
echo "==> Starting emulator '$AVD_NAME' with GPU acceleration (-gpu host)"
emulator -avd "$AVD_NAME" -gpu host -no-audio >"$EMU_LOG" 2>&1 &
else
echo "==> No display -- starting emulator '$AVD_NAME' headless (-gpu swiftshader_indirect)"
emulator -avd "$AVD_NAME" -gpu swiftshader_indirect -no-audio -no-window \
>"$EMU_LOG" 2>&1 &
fi
EMU_PID=$!
i=0
booted=""
while [ "$i" -lt 150 ]; do
if ! kill -0 "$EMU_PID" 2>/dev/null; then
echo "Emulator process exited unexpectedly. Log output:" >&2
cat "$EMU_LOG" >&2
exit 1
fi
SERIAL=$(avd_serial "$AVD_NAME")
if [ -n "$SERIAL" ]; then
booted=$(adb -s "$SERIAL" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')
[ "$booted" = "1" ] && break
fi
i=$((i + 1))
sleep 2
done
if [ "$booted" != "1" ]; then
echo "Emulator did not finish booting in time. Log output:" >&2
cat "$EMU_LOG" >&2
exit 1
fi
fi
echo "==> Building debug APK" echo "==> Building debug APK"
./gradlew :androidApp:assembleDebug ./gradlew :androidApp:assembleDebug
APK="androidApp/build/outputs/apk/debug/androidApp-debug.apk" APK="androidApp/build/outputs/apk/debug/androidApp-debug.apk"
echo "==> Installing and launching $APK" echo "==> Installing and launching $APK"
adb -s "$SERIAL" install -r "$APK" # ANDROID_SERIAL above is what aims these; the adb wrapper would work it out
adb -s "$SERIAL" shell am start -n "$APP_ID/.MainActivity" # from the checkout anyway, but a script that says which device it means does
# not depend on being run from the right directory.
adb install -r "$APK"
adb shell am start -n "$APP_ID/.MainActivity"
+30 -1
View File
@@ -17,7 +17,9 @@
//! `reset` frame plus the newest window) //! `reset` frame plus the newest window)
//! POST /sessions/{id}/message {text, attachmentIds?} //! POST /sessions/{id}/message {text, attachmentIds?}
//! POST /sessions/{id}/answer {questionId, answers} (questions and permissions) //! POST /sessions/{id}/answer {questionId, answers} (questions and permissions)
//! POST /sessions/{id}/interrupt //! POST /sessions/{id}/interrupt stop the running turn; the process stays
//! POST /sessions/{id}/stop end the process; the session and transcript stay
//! POST /sessions/{id}/start run the process again, continuing the conversation
//! POST /sessions/{id}/title {title} //! POST /sessions/{id}/title {title}
//! POST /sessions/{id}/model {model} //! POST /sessions/{id}/model {model}
//! POST /sessions/{id}/command {text} -- /compact, /clear, /rename x, or the dialect's own //! POST /sessions/{id}/command {text} -- /compact, /clear, /rename x, or the dialect's own
@@ -87,6 +89,8 @@ pub fn router(manager: Arc<SessionManager>) -> Router {
.route("/sessions/{id}/message", post(message)) .route("/sessions/{id}/message", post(message))
.route("/sessions/{id}/answer", post(answer)) .route("/sessions/{id}/answer", post(answer))
.route("/sessions/{id}/interrupt", post(interrupt)) .route("/sessions/{id}/interrupt", post(interrupt))
.route("/sessions/{id}/stop", post(stop))
.route("/sessions/{id}/start", post(start))
.route("/sessions/{id}/title", post(rename)) .route("/sessions/{id}/title", post(rename))
.route("/sessions/{id}/model", post(set_model)) .route("/sessions/{id}/model", post(set_model))
.route("/sessions/{id}/permission-mode", post(set_permission_mode)) .route("/sessions/{id}/permission-mode", post(set_permission_mode))
@@ -678,6 +682,31 @@ async fn interrupt(
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
/// Ends the session's process. The session stays, and `start` brings it
/// back -- see [`SessionManager::stop_session`].
///
/// Not `lookup`ed: a session that failed to relaunch has no live entry and
/// may still have a process running, which is exactly one worth being able
/// to stop.
async fn stop(
State(manager): State<Arc<SessionManager>>,
UrlPath(id): UrlPath<String>,
) -> Result<StatusCode, ApiError> {
manager.stop_session(&id).map_err(bad_request)?;
Ok(StatusCode::NO_CONTENT)
}
/// Starts a process for a session that has none, continuing the same
/// conversation -- see [`SessionManager::start_session`], which refuses
/// unless the session is known to have exited.
async fn start(
State(manager): State<Arc<SessionManager>>,
UrlPath(id): UrlPath<String>,
) -> Result<StatusCode, ApiError> {
manager.start_session(&id).map_err(bad_request)?;
Ok(StatusCode::NO_CONTENT)
}
/// The usage screen needs two things that live in different places: the /// The usage screen needs two things that live in different places: the
/// cache, and the current list of machines to ask. Carried together rather /// cache, and the current list of machines to ask. Carried together rather
/// than the monitor holding the manager, which would point the dependency /// than the monitor holding the manager, which would point the dependency
+33 -7
View File
@@ -100,12 +100,6 @@ pub(super) mod translate;
const RESUME_FILE: &str = "claude-session.json"; const RESUME_FILE: &str = "claude-session.json";
/// Grace period between asking a process to stop and killing it.
///
/// Only [`Driver::stop`] uses it -- a session being deleted. Detaching
/// does not stop anything, so it has no grace period and needs none.
const STOP_GRACE: std::time::Duration = std::time::Duration::from_secs(5);
/// Messages handed to the CLI that it has not visibly acted on yet. /// Messages handed to the CLI that it has not visibly acted on yet.
/// ///
/// The CLI *does* take a message written mid-turn: it goes into the next /// The CLI *does* take a message written mid-turn: it goes into the next
@@ -239,6 +233,10 @@ impl ClaudeDriver {
// to find. Nothing was ever recorded for one, so this answers "no" // to find. Nothing was ever recorded for one, so this answers "no"
// without needing to know that, which is why the remote case is // without needing to know that, which is why the remote case is
// not a branch here. // not a branch here.
// Whether this launch *started* a process or picked up one that was
// already there. The two owe the session different things -- see the
// `Status` below.
let started_here;
let record = match process::recorded(session_dir) { let record = match process::recorded(session_dir) {
// Still running, and ours. Pick it up where it was left -- // Still running, and ours. Pick it up where it was left --
// the one path that must not pass `--resume`. // the one path that must not pass `--resume`.
@@ -249,6 +247,7 @@ impl ClaudeDriver {
provider.name, provider.name,
record.pid record.pid
); );
started_here = false;
record record
} }
// Recorded, and the machine will not say whether it is still // Recorded, and the machine will not say whether it is still
@@ -262,12 +261,39 @@ impl ClaudeDriver {
meta.id, meta.id,
record.pid record.pid
); );
started_here = false;
record record
} }
Some((_, process::Liveness::Dead)) | None => { Some((_, process::Liveness::Dead)) | None => {
started_here = true;
Self::start(meta, provider, transport, session_dir)? Self::start(meta, provider, transport, session_dir)?
} }
}; };
// A process this driver has just started has been asked for nothing,
// which is what idle means. Said here because nothing else will say
// it: the CLI writes not one line until it is given work, so a
// session whose transcript last recorded `Exited` -- one whose
// process died while this server was down, or one somebody stopped
// from the phone -- would keep that word. `Exited` refuses every
// command sent to the session, and it offers a phone the chance to
// start a second CLI against a conversation that already has one.
//
// From the driver rather than from the manager, and before `follow`
// is spawned, so it cannot overtake the exit `follow` reports for a
// process that dies immediately: both come from here, in this order.
// Adopting says nothing, because a process that was already running
// may be mid-turn, and the transcript's last word is the better
// answer until its output says otherwise.
//
// The llama driver has always done this (see `LlamaDriver::attached`,
// which reports `Running` while the model loads and `Idle` when it
// answers); this side was the one silent about it.
if started_here {
let _ = sink.send(Event::Status {
state: SessionStatus::Idle,
});
}
// Where reading of its output had reached. A process just started // Where reading of its output had reached. A process just started
// has said nothing, so its record says zero and this is the same // has said nothing, so its record says zero and this is the same
// question with the same answer. // question with the same answer.
@@ -673,7 +699,7 @@ impl Driver for ClaudeDriver {
fn stop(&self) { fn stop(&self) {
self.reading.store(false, Ordering::SeqCst); self.reading.store(false, Ordering::SeqCst);
if let Some(record) = process::live(&self.session_dir) { if let Some(record) = process::live(&self.session_dir) {
process::stop(&record, STOP_GRACE); process::stop(&record, process::STOP_GRACE);
} }
process::clear(&self.session_dir); process::clear(&self.session_dir);
} }
+1 -4
View File
@@ -269,9 +269,6 @@ const SERVER_LOG: &str = "llama-server.log";
/// seconds late costs nothing. /// seconds late costs nothing.
const WATCH_INTERVAL: std::time::Duration = std::time::Duration::from_secs(2); const WATCH_INTERVAL: std::time::Duration = std::time::Duration::from_secs(2);
/// Grace period between asking llama-server to stop and killing it.
const STOP_GRACE: std::time::Duration = std::time::Duration::from_secs(5);
/// An owner-only log opened for appending, so the two streams pointed at /// An owner-only log opened for appending, so the two streams pointed at
/// it do not overwrite each other and a reattach keeps what came before. /// it do not overwrite each other and a reattach keeps what came before.
fn log_file(path: &Path) -> Result<std::fs::File> { fn log_file(path: &Path) -> Result<std::fs::File> {
@@ -447,7 +444,7 @@ impl Driver for LlamaDriver {
fn stop(&self) { fn stop(&self) {
self.cancel.store(true, Ordering::Relaxed); self.cancel.store(true, Ordering::Relaxed);
if let Some(record) = process::live(&self.session_dir) { if let Some(record) = process::live(&self.session_dir) {
process::stop(&record, STOP_GRACE); process::stop(&record, process::STOP_GRACE);
} }
process::clear(&self.session_dir); process::clear(&self.session_dir);
} }
+293 -33
View File
@@ -169,11 +169,21 @@ pub struct SessionInfo {
pub created: f64, pub created: f64,
} }
/// What is running a session at this moment.
///
/// Behind a lock because a session outlives its process: stopping one and
/// starting it again replaces the driver while the transcript, the event
/// pump and the stream every open phone is reading stay exactly where they
/// were. Shared with [`Commands`] rather than copied into it, because two
/// holders of "the driver" are two answers to that question the moment one
/// of them is replaced.
type DriverCell = Arc<Mutex<Arc<dyn Driver>>>;
/// A running session: its driver plus the shared state the event pump /// A running session: its driver plus the shared state the event pump
/// keeps current. Cheap to clone-by-`Arc` into request handlers. /// keeps current. Cheap to clone-by-`Arc` into request handlers.
pub struct LiveSession { pub struct LiveSession {
meta: SessionConfig, meta: SessionConfig,
driver: Arc<dyn Driver>, driver: DriverCell,
/// Commands asked for and not yet run, oldest first, with the pump /// Commands asked for and not yet run, oldest first, with the pump
/// that will run them. Shared with that pump, which is what notices /// that will run them. Shared with that pump, which is what notices
/// the boundary. /// the boundary.
@@ -195,12 +205,17 @@ pub struct LiveSession {
/// for the turn to end. Drivers therefore never have to think about it, /// for the turn to end. Drivers therefore never have to think about it,
/// and a new provider cannot get it wrong by omission. /// and a new provider cannot get it wrong by omission.
struct Commands { struct Commands {
driver: Arc<dyn Driver>, driver: DriverCell,
sink: EventSink, sink: EventSink,
waiting: Mutex<VecDeque<(String, SessionCommand)>>, waiting: Mutex<VecDeque<(String, SessionCommand)>>,
} }
impl Commands { impl Commands {
/// Whatever is driving the session now -- see [`DriverCell`].
fn driver(&self) -> Arc<dyn Driver> {
self.driver.lock().unwrap().clone()
}
/// Runs `command` now if the session is between turns, holds it until /// Runs `command` now if the session is between turns, holds it until
/// it is, and refuses it outright if there will never be one. Whichever /// it is, and refuses it outright if there will never be one. Whichever
/// happened, the phone is told. /// happened, the phone is told.
@@ -236,9 +251,10 @@ impl Commands {
}); });
return; return;
} }
if self.driver.between_turns() { let driver = self.driver();
if driver.between_turns() {
let _ = self.sink.send(Event::CommandSent { id, text }); let _ = self.sink.send(Event::CommandSent { id, text });
command.apply(self.driver.as_ref()); command.apply(driver.as_ref());
return; return;
} }
let _ = self.sink.send(Event::CommandQueued { let _ = self.sink.send(Event::CommandQueued {
@@ -258,7 +274,8 @@ impl Commands {
/// began by itself, which it does: a background task finishing makes it /// began by itself, which it does: a background task finishing makes it
/// pick the conversation back up with nothing written to it. /// pick the conversation back up with nothing written to it.
fn take_one(&self) { fn take_one(&self) {
if !self.driver.between_turns() { let driver = self.driver();
if !driver.between_turns() {
return; return;
} }
let Some((id, command)) = self.waiting.lock().unwrap().pop_front() else { let Some((id, command)) = self.waiting.lock().unwrap().pop_front() else {
@@ -268,7 +285,7 @@ impl Commands {
id, id,
text: command.label(), text: command.label(),
}); });
command.apply(self.driver.as_ref()); command.apply(driver.as_ref());
} }
/// Gives up on everything held, because the session cannot run them. /// Gives up on everything held, because the session cannot run them.
@@ -336,6 +353,11 @@ struct Shared {
} }
impl LiveSession { impl LiveSession {
/// Whatever is driving this session now -- see [`DriverCell`].
fn driver(&self) -> Arc<dyn Driver> {
self.driver.lock().unwrap().clone()
}
/// Hands the user's message to the driver, which records it in the /// Hands the user's message to the driver, which records it in the
/// transcript by reporting that it has taken it -- see `MessageTaken`. /// transcript by reporting that it has taken it -- see `MessageTaken`.
/// ///
@@ -348,7 +370,7 @@ impl LiveSession {
// drew a person's screenshot as a row floating above the bubble // drew a person's screenshot as a row floating above the bubble
// that sent it, and left the phone inferring from adjacency which // that sent it, and left the phone inferring from adjacency which
// message an image went with -- a thing the sender already knew. // message an image went with -- a thing the sender already knew.
self.driver.send_user_message(text, images); self.driver().send_user_message(text, images);
} }
pub fn answer_question(&self, question_id: &str, answers: &[String]) { pub fn answer_question(&self, question_id: &str, answers: &[String]) {
@@ -356,7 +378,7 @@ impl LiveSession {
id: question_id.to_string(), id: question_id.to_string(),
answers: answers.to_vec(), answers: answers.to_vec(),
}); });
self.driver.answer_question(question_id, answers); self.driver().answer_question(question_id, answers);
} }
/// Asks the session to run a command on itself, now or at the next /// Asks the session to run a command on itself, now or at the next
@@ -367,14 +389,14 @@ impl LiveSession {
} }
pub fn interrupt(&self) { pub fn interrupt(&self) {
self.driver.interrupt(); self.driver().interrupt();
} }
/// Leaves this session's process running and stops attending to it, /// Leaves this session's process running and stops attending to it,
/// for a server that is going away and means to come back. See /// for a server that is going away and means to come back. See
/// [`Driver::detach`]. /// [`Driver::detach`].
pub fn detach(&self) { pub fn detach(&self) {
self.driver.detach(); self.driver().detach();
} }
/// Compacts at the next boundary. Through the command queue like /// Compacts at the next boundary. Through the command queue like
@@ -942,7 +964,7 @@ impl SessionManager {
// change, and as an error if it cannot. The config above is a // change, and as an error if it cannot. The config above is a
// different question -- what to launch this session with next // different question -- what to launch this session with next
// time -- and it is answered by the request. // time -- and it is answered by the request.
session.driver.set_permission_mode(mode); session.driver().set_permission_mode(mode);
} }
Ok(()) Ok(())
} }
@@ -1025,11 +1047,144 @@ impl SessionManager {
if let Some(session) = inner.live.get(id) { if let Some(session) = inner.live.get(id) {
// See `set_session_permission_mode`: the driver reports what // See `set_session_permission_mode`: the driver reports what
// it is set to, this only asks. // it is set to, this only asks.
session.driver.set_model(model); session.driver().set_model(model);
} }
Ok(()) Ok(())
} }
/// Ends this session's process, leaving the session -- its transcript,
/// its place in the list, everything a phone is watching -- exactly
/// where it is. [`SessionManager::start_session`] is the way back.
///
/// The signal is all this does. Whether the process actually went, what
/// it said on the way out, and the `Exited` that follows are reported by
/// the path a session that died on its own already takes: the driver's
/// own reader notices within a poll, drains what was still unread, and
/// records it. Announcing it from here would be this side's guess
/// arriving ahead of the measurement, and it would be wrong for the five
/// seconds a process that ignores SIGTERM keeps running.
///
/// Deliberately not routed through the driver. The record is the
/// session's rather than any dialect's -- `session::process` writes it
/// for every provider that has a process at all -- so asking it here
/// stops a session whose driver is in no state to be asked, and adds no
/// method a new driver could implement wrongly.
pub fn stop_session(&self, id: &str) -> Result<()> {
if !self
.inner
.read()
.unwrap()
.config
.sessions
.iter()
.any(|meta| meta.id == id)
{
bail!("no session {id}");
}
// Three answers, and they are three different things to tell
// somebody: it is running (stop it), it is not (nothing to do), and
// nobody could find out (nothing was signalled, and saying "nothing
// is running" would be inventing the answer).
let record = match process::recorded(&self.data_dir.join(id)) {
Some((record, process::Liveness::Alive)) => record,
Some((_, process::Liveness::Unknown)) => bail!(
"this machine won't say whether this session's process is still running, so it \
wasn't signalled"
),
Some((_, process::Liveness::Dead)) | None => {
bail!("this session has no process running")
}
};
tracing::info!("stopping session {id} (pid {})", record.pid);
process::stop(&record, process::STOP_GRACE);
Ok(())
}
/// Starts a process for a session whose process has ended, continuing
/// the same conversation -- for Claude Code, the `--resume` that crash
/// recovery already uses.
///
/// Only the driver is new. The transcript, the event pump and the stream
/// every open phone is reading stay as they were, so this is not a
/// reconnect for anybody watching -- and there is still exactly one
/// writer of the transcript, which relaunching the whole session would
/// not be.
///
/// Refused unless the session is *known* to have exited. `Unknown` means
/// nobody could find out whether the process is alive, and starting one
/// on that is precisely the second-CLI-on-one-conversation fault that
/// `session::process` exists to prevent.
///
/// What the session then *reports* is the driver's to say, not this
/// function's: the phone's list reads the manager's status and the
/// session screen replays the transcript, so a status written in one and
/// not the other is two screens disagreeing about one session -- which
/// is what a status set here without an event produced, visible as a
/// stop button that turned into a play button a moment after the screen
/// opened.
pub fn start_session(&self, id: &str) -> Result<()> {
let mut inner = self.inner.write().unwrap();
let meta = inner
.config
.sessions
.iter()
.find(|meta| meta.id == id)
.with_context(|| format!("no session {id}"))?
.clone();
let existing = inner.live.get(id).cloned();
let status = match &existing {
Some(session) => *session.shared.status.lock().unwrap(),
None => status_of_unlaunched(&self.data_dir.join(id)),
};
match status {
SessionStatus::Exited => {}
SessionStatus::Unknown => bail!(
"this machine won't say whether this session's process is still running, so \
nothing was started"
),
_ => bail!("this session is already running"),
}
// Fresh from the config, like every other launch: a model or a
// permission mode changed while the session was stopped is what it
// starts with.
let (setup, provider) = resolve(&inner.config, &meta)?;
match existing {
Some(session) => {
*session.driver.lock().unwrap() = make_driver(
&meta,
&setup,
&provider,
&self.models_dir,
session.dir(),
session.transcript_path(),
&session.sink,
)?;
}
// Nothing is live for this one -- a session whose launch failed
// when the server started, which has no pump either. That is the
// whole of `launch`, and the same call the server start makes.
None => {
let session = launch(
meta,
&setup,
&provider,
&self.data_dir,
&self.models_dir,
None,
self.notifications.clone(),
)?;
inner.live.insert(id.to_string(), session);
}
}
// Nothing is announced from here. A driver that starts a process
// reports the session idle itself, in order with everything else it
// says about that process -- see `ClaudeDriver::launch`. Saying it
// here as well would be a second writer of the same fact, and the
// one that cannot see whether the process it is describing is still
// there.
Ok(())
}
/// Kills the process, releases everything the spawn created, and /// Kills the process, releases everything the spawn created, and
/// deletes the transcript and files -- the complete path out. /// deletes the transcript and files -- the complete path out.
pub fn delete_session(&self, id: &str) -> Result<()> { pub fn delete_session(&self, id: &str) -> Result<()> {
@@ -1045,7 +1200,7 @@ impl SessionManager {
// Stopped, not detached: this is the one exit where the // Stopped, not detached: this is the one exit where the
// process must not survive, because the conversation it // process must not survive, because the conversation it
// belongs to is being removed. See `Driver::stop`. // belongs to is being removed. See `Driver::stop`.
session.driver.stop(); session.driver().stop();
} }
let dir = self.data_dir.join(id); let dir = self.data_dir.join(id);
if dir.exists() { if dir.exists() {
@@ -1339,25 +1494,15 @@ fn launch(
); );
} }
let driver: Arc<dyn Driver> = match provider.kind { let driver = Arc::new(Mutex::new(make_driver(
DriverKind::Echo => Arc::new(EchoDriver::new(sink.clone(), dir.clone())), &meta,
DriverKind::LlamaCpp => Arc::new(LlamaDriver::launch( setup,
&meta, provider,
provider, models_dir,
&Transport::for_setup(setup), &dir,
models_dir, &transcript_path,
&transcript_path, &sink,
&dir, )?));
sink.clone(),
)?),
DriverKind::ClaudeCli => Arc::new(ClaudeDriver::launch(
&meta,
provider,
&Transport::for_setup(setup),
&dir,
sink.clone(),
)?),
};
let commands = Arc::new(Commands { let commands = Arc::new(Commands {
driver: Arc::clone(&driver), driver: Arc::clone(&driver),
@@ -1386,6 +1531,44 @@ fn launch(
})) }))
} }
/// Whatever runs this session's provider, pointed at the session's own
/// directory and reporting into `sink`.
///
/// Split out of [`launch`] because a session outlives its process: it is
/// also what [`SessionManager::start_session`] builds when somebody starts a
/// stopped session again. That path replaces the driver and nothing else, so
/// it has to construct one the same way rather than becoming a second answer
/// to "what runs this".
fn make_driver(
meta: &SessionConfig,
setup: &SetupConfig,
provider: &ProviderConfig,
models_dir: &Path,
dir: &Path,
transcript_path: &Path,
sink: &EventSink,
) -> Result<Arc<dyn Driver>> {
Ok(match provider.kind {
DriverKind::Echo => Arc::new(EchoDriver::new(sink.clone(), dir.to_path_buf())),
DriverKind::LlamaCpp => Arc::new(LlamaDriver::launch(
meta,
provider,
&Transport::for_setup(setup),
models_dir,
transcript_path,
dir,
sink.clone(),
)?),
DriverKind::ClaudeCli => Arc::new(ClaudeDriver::launch(
meta,
provider,
&Transport::for_setup(setup),
dir,
sink.clone(),
)?),
})
}
/// The one writer of a session's transcript: assigns sequence numbers, /// The one writer of a session's transcript: assigns sequence numbers,
/// appends, updates the shared status/activity view, fans out. Ends when /// appends, updates the shared status/activity view, fans out. Ends when
/// every sender is dropped -- i.e. when the session is deleted and its /// every sender is dropped -- i.e. when the session is deleted and its
@@ -1639,7 +1822,10 @@ mod tests {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let (sink, mut events) = mpsc::unbounded_channel(); let (sink, mut events) = mpsc::unbounded_channel();
let commands = Commands { let commands = Commands {
driver: Arc::new(EchoDriver::new(sink.clone(), dir.path().to_path_buf())), driver: Arc::new(Mutex::new(Arc::new(EchoDriver::new(
sink.clone(),
dir.path().to_path_buf(),
)))),
sink, sink,
waiting: Mutex::new(VecDeque::new()), waiting: Mutex::new(VecDeque::new()),
}; };
@@ -2233,6 +2419,80 @@ mod tests {
std::fs::write(path, rewritten).expect("write transcript"); std::fs::write(path, rewritten).expect("write transcript");
} }
/// Stopping and starting a session is about its *process*, and the two
/// refusals are the whole of what keeps starting one from becoming a
/// second one on the same conversation.
///
/// Echo has no process, which makes it the right session to ask the
/// first question of: "there is nothing to stop" is an answer, and
/// reporting success would leave a phone showing a session it believes
/// it stopped. The second question is asked of a session that has been
/// told it exited, since the guard is on the *status* rather than on
/// which driver it is.
#[tokio::test]
async fn a_session_is_started_again_only_once_it_is_known_to_have_exited() {
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, data_dir.clone(), data_dir.join("models"))
.expect("manager");
let info = manager.spawn_session(echo_spec()).expect("spawn");
let session = manager.session(&info.id).expect("live session");
let mut rx = session.subscribe();
let refused = manager.stop_session(&info.id).expect_err("nothing to stop");
assert!(
refused.to_string().contains("no process running"),
"said: {refused:#}"
);
let refused = manager
.start_session(&info.id)
.expect_err("already running");
assert!(
refused.to_string().contains("already running"),
"said: {refused:#}"
);
// What a driver reports when its process goes, without a process
// to go: the guard reads the recorded status, so this is the same
// state a stopped claude session reaches.
let _ = session.sink.send(Event::Status {
state: SessionStatus::Exited,
});
collect_until(&mut rx, |event| {
matches!(
event,
Event::Status {
state: SessionStatus::Exited
}
)
})
.await;
manager.start_session(&info.id).expect("start again");
collect_until(&mut rx, is_idle).await;
// Idle rather than exited, and *recorded* -- said by the driver that
// was just built, like every driver says what state it is starting
// in. The manager writing it directly is what made the phone's list
// and its session screen disagree: one reads this status and the
// other replays the transcript, so a status in only one of them is
// two screens describing one session differently.
assert_eq!(manager.sessions()[0].status, SessionStatus::Idle);
assert_eq!(
Transcript::open(&data_dir.join(&info.id).join("transcript.jsonl"))
.expect("reopen transcript")
.last_status(),
Some(SessionStatus::Idle),
);
// The same live session throughout: only the driver was replaced,
// so nothing a phone is reading was interrupted.
assert!(Arc::ptr_eq(
&session,
&manager.session(&info.id).expect("still live")
));
}
#[tokio::test] #[tokio::test]
async fn a_restart_relaunches_sessions_and_continues_the_numbering() { async fn a_restart_relaunches_sessions_and_continues_the_numbering() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
+7
View File
@@ -204,6 +204,13 @@ pub fn clear(session_dir: &Path) {
} }
} }
/// Grace period between asking a session's process to stop and killing it.
///
/// Here rather than beside each caller: it is a property of stopping one of
/// these, and two drivers plus the manager had written the same five seconds
/// down separately, which is three places for it to drift.
pub const STOP_GRACE: std::time::Duration = std::time::Duration::from_secs(5);
/// Asks it to stop, then makes sure. Used where a leaked process must /// Asks it to stop, then makes sure. Used where a leaked process must
/// actually end: a deleted session, or one being replaced. /// actually end: a deleted session, or one being replaced.
/// ///