Merge branch 'main' of git.arirex.me:iris/ai-app
This commit is contained in:
commit
1ed6e29bc6
14 files changed
+628
-171
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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.
|
//
|
||||||
|
// Always here, rather than arriving with the turn as it used to. A control that
|
||||||
|
// comes and goes makes its own presence the signal, and its absence could not say
|
||||||
|
// 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.
|
||||||
|
val process =
|
||||||
|
when {
|
||||||
|
running -> ProcessAction.Pause
|
||||||
|
status == "exited" -> ProcessAction.Start
|
||||||
|
else -> ProcessAction.Stop
|
||||||
|
}
|
||||||
Button(
|
Button(
|
||||||
onClick = { act { interruptSession(settings, summary.id) } },
|
onClick = { act { process.perform(settings, summary.id) } },
|
||||||
colors = actionButtonColors(stopColor),
|
colors = actionButtonColors(process.colour()),
|
||||||
) {
|
) {
|
||||||
// A filled square, which is what stop has looked like since tape decks.
|
|
||||||
Glyph(
|
Glyph(
|
||||||
STOP_GLYPH,
|
process.glyph,
|
||||||
colour = LocalContentColor.current,
|
colour = LocalContentColor.current,
|
||||||
modifier = Modifier.semantics { contentDescription = "Stop" },
|
modifier = Modifier.semantics { contentDescription = process.label },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(8.dp))
|
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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
+289
-29
@@ -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())),
|
|
||||||
DriverKind::LlamaCpp => Arc::new(LlamaDriver::launch(
|
|
||||||
&meta,
|
&meta,
|
||||||
|
setup,
|
||||||
provider,
|
provider,
|
||||||
&Transport::for_setup(setup),
|
|
||||||
models_dir,
|
models_dir,
|
||||||
|
&dir,
|
||||||
&transcript_path,
|
&transcript_path,
|
||||||
&dir,
|
&sink,
|
||||||
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");
|
||||||
|
|||||||
@@ -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.
|
||||||
///
|
///
|
||||||
|
|||||||
Reference in new issue
Block a user