Keep a subagent's words in its own transcript, and count the ones already running
Two corrections to the previous commit. A subagent's closing report belongs in the subagent's transcript, which is where it already is; drawing it as a card in the parent's put the same paragraph in two places for a reader who did not ask for it. The row is a divider now -- a boundary, which is what the transcript actually needed there -- closed, saying only what reported and how it went. Opening it shows the report anyway, since leaving the conversation to read one line has its own cost, and a backgrounded command has no transcript of its own so this is the only place its report exists at all: that one names itself from its summary and has nothing left to open. `TranscriptDivider` grew a `trailing` slot for the chevron rather than the row growing its own copy of the rules. And the status was wrong for a session that was already running before the update, which is every session when the backend is replaced under it. Adoption picks a session's stdout back up from a recorded offset, so the `task_started` lines for subagents launched earlier are behind it and the translator never saw them -- it started with an empty set and reported `idle` with a subagent plainly still working. `Subagents::any_open` reads the directory instead, which is a measurement rather than bookkeeping and is right for a session this process did not start. Both sources are kept and neither subsumes the other: the translator's own set is the only thing that knows about a backgrounded *command*, which has no subagent to be found. The same pair decides whether an ending has already been reported, so a task that began before the restart still gets its divider. Echo's helpers now record their report as their own subagent's closing text, the way the real driver does, so the fixture has the shape being tested. Verified on the emulator: three dividers closed, one opened to its report, and each reply drawn as its own message. 170 server tests, ktfmt, clippy, rustfmt, Android lint and the JVM unit tests all clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5711c2568a
commit
ef1aad8776
11 files changed
+343
-121
No files matched your search
@@ -241,12 +241,24 @@ sent while a subagent runs is not held until the subagent finishes; and
|
|||||||
`sessionWorking("waiting")` is deliberately **false** — nothing is being
|
`sessionWorking("waiting")` is deliberately **false** — nothing is being
|
||||||
written, and the fold uses that same predicate to decide a reply is settled.
|
written, and the fold uses that same predicate to decide a reply is settled.
|
||||||
|
|
||||||
- **A task reporting back is a row** (`Event::TaskNote`, `TaskNoteRow`), and
|
- **A task reporting back is a divider** (`Event::TaskNote`, `TaskNoteRow`),
|
||||||
the reply that answers it is a **new** message. The fold refuses to grow a
|
and the reply that answers it is a **new** message. The fold refuses to grow
|
||||||
settled reply; without that, two turns with nothing recorded between them
|
a settled reply; without that, two turns with nothing recorded between them
|
||||||
were folded into one and ran together mid-sentence. `./ui-sandbox.sh` plus
|
were folded into one and ran together mid-sentence. The divider is **closed**
|
||||||
`/subagent 8` in an echo session is the whole rig — the helpers stagger a
|
and does not say what the subagent said: that is recorded as the subagent's
|
||||||
second apart so each report and the reply to it are legible.
|
own transcript's closing text and belongs there, not repeated in its
|
||||||
|
parent's. Opening it shows the report anyway, and a backgrounded *command*
|
||||||
|
— which has no transcript of its own — names itself from its summary and has
|
||||||
|
nothing left to open. `./ui-sandbox.sh` plus `/subagent 8` in an echo session
|
||||||
|
is the whole rig; the helpers stagger a second apart so each report and the
|
||||||
|
reply to it are legible.
|
||||||
|
- **Whether work is outstanding has two sources and needs both.** The
|
||||||
|
translator's `open_tasks` is what it watched start — the only thing that
|
||||||
|
knows about a backgrounded command — and `Subagents::any_open` reads the
|
||||||
|
directory, which is the only thing that knows about a subagent started
|
||||||
|
before this translator existed. That second one is every subagent a session
|
||||||
|
has when the backend is updated under it: adoption reads stdout from a
|
||||||
|
recorded offset, so those `task_started` lines are already behind it.
|
||||||
- **A usage limit a subagent hits reaches the session**, not just the
|
- **A usage limit a subagent hits reaches the session**, not just the
|
||||||
subagent's own transcript; auto-resume can only schedule against a session.
|
subagent's own transcript; auto-resume can only schedule against a session.
|
||||||
That is the case where the main agent is idle and a background Task is
|
That is the case where the main agent is idle and a background Task is
|
||||||
|
|||||||
@@ -678,22 +678,35 @@ mid-sentence with not even a space between them.
|
|||||||
Both halves were wrong and both are fixed. The fold now refuses to grow a
|
Both halves were wrong and both are fixed. The fold now refuses to grow a
|
||||||
*settled* reply, so a turn boundary is always a message boundary whatever
|
*settled* reply, so a turn boundary is always a message boundary whatever
|
||||||
caused it (`joinPages` carries the same rule across a page boundary). And the
|
caused it (`joinPages` carries the same rule across a page boundary). And the
|
||||||
notification is recorded as `Event::TaskNote`, drawn as a card naming who
|
notification is recorded as `Event::TaskNote`, on its own row rather than as an
|
||||||
reported and what they said — a card rather than a divider, because somebody
|
update to the Task call's — that row is wherever the call was made, above
|
||||||
said this, and its own row rather than an update to the Task call's, which is
|
everything the session has said since, and it would change where no reader is
|
||||||
above everything the session has said since and would change where no reader
|
looking.
|
||||||
is looking.
|
|
||||||
|
**Drawn as a divider, closed, and not carrying the subagent's words.** It
|
||||||
|
marks a boundary, which is what the reader needs from it; the subagent's
|
||||||
|
closing report is recorded as that subagent's own transcript's closing text,
|
||||||
|
and repeating it in the parent puts the same paragraph in two places for
|
||||||
|
somebody who did not ask for it. Opening the divider shows it anyway, because
|
||||||
|
leaving the conversation to read one line has its own cost — and because a
|
||||||
|
backgrounded *command* has no transcript of its own, so this is the only place
|
||||||
|
its report exists at all. That one names itself from its summary and has
|
||||||
|
nothing left to open.
|
||||||
|
|
||||||
`status` is carried beside `summary` rather than folded into it because the
|
`status` is carried beside `summary` rather than folded into it because the
|
||||||
summary is absent exactly when things went wrong, and "finished" is the wrong
|
summary is absent exactly when things went wrong, and "finished" is the wrong
|
||||||
word for a task that was killed. `title` is the subagent's; a backgrounded
|
word for a task that was killed.
|
||||||
command has none and its summary names itself, so the card says "a background
|
|
||||||
task" rather than inventing one.
|
|
||||||
|
|
||||||
Reported once. The two lifecycle shapes (`task_notification` and
|
Reported once. The two lifecycle shapes (`task_notification` and
|
||||||
`task_updated`) can both arrive for one task, and the translator's `tasks` map
|
`task_updated`) can both arrive for one task, and whichever gets here first is
|
||||||
is what says which got there first — removing the entry is also what stops the
|
the one that finds the task open — in the translator's own `open_tasks`, or
|
||||||
task counting as outstanding, which is what decides `Status::Waiting`.
|
failing that in the registry, which is what makes an **adopted** session work.
|
||||||
|
A backend restart picks a session's stdout back up from a recorded offset, so
|
||||||
|
the `task_started` lines for anything already running are behind it and the
|
||||||
|
translator never sees them; `Subagents::any_open` is the measurement that
|
||||||
|
covers those, and `open_tasks` covers the backgrounded command, which has no
|
||||||
|
subagent to be found in the registry at all. Both are needed and neither
|
||||||
|
subsumes the other.
|
||||||
|
|
||||||
### A limit a subagent hits is the session's (2026-09-06)
|
### A limit a subagent hits is the session's (2026-09-06)
|
||||||
|
|
||||||
|
|||||||
+13
-10
@@ -81,19 +81,22 @@ transcript is still being written to and its process is the session's to stop.
|
|||||||
`Waiting`, so the end-of-turn status `dispatch` produces for an ordinary
|
`Waiting`, so the end-of-turn status `dispatch` produces for an ordinary
|
||||||
session is dropped rather than written.
|
session is dropped rather than written.
|
||||||
|
|
||||||
**The ending is also reported to the parent** (2026-09-06), as
|
**The ending is also marked in the parent** (2026-09-06), as
|
||||||
`Event::TaskNote { about, title, status, summary }`: the notification is a
|
`Event::TaskNote { about, title, status, summary }`: the turn the session
|
||||||
message the session received, and the turn it wakes up and runs would
|
wakes up and runs would otherwise begin with nothing in front of it, which
|
||||||
otherwise begin with nothing in front of it -- which drew two replies as
|
drew two replies as one paragraph. It is a **divider**, closed, and does
|
||||||
one paragraph. Reported once however many of the two lifecycle shapes
|
not repeat the summary -- that is this subagent's own closing text, and
|
||||||
arrive; the `task_id -> tool_use_id` entry is removed as it is reported,
|
here is not where somebody reads it. Reported once however many of the two
|
||||||
which is what says the first one got there. See PLAN.md's "A task
|
lifecycle shapes arrive: whichever gets there first is the one that finds
|
||||||
|
the task still open, and `finish` below closes it. See PLAN.md's "A task
|
||||||
reporting back".
|
reporting back".
|
||||||
|
|
||||||
**While any task is outstanding the session's turn ends in
|
**While any task is outstanding the session's turn ends in
|
||||||
`Status Waiting` rather than `Idle`** -- the same `tasks` map, asked
|
`Status Waiting` rather than `Idle`.** `Idle` means "waiting for a person",
|
||||||
whether it is empty. `Idle` means "waiting for a person", and a session
|
and a session with a backgrounded subagent is not doing that. Two sources:
|
||||||
with a backgrounded subagent is not doing that.
|
the translator's `open_tasks`, and `Subagents::any_open` -- which is what
|
||||||
|
covers a subagent launched before a backend restart adopted the session,
|
||||||
|
whose `task_started` is behind the offset its stdout is read from.
|
||||||
|
|
||||||
**A limit the account hits inside a subagent is hoisted to the session**
|
**A limit the account hits inside a subagent is hoisted to the session**
|
||||||
as well as recorded here, because `resume.rs` can only schedule against a
|
as well as recorded here, because `resume.rs` can only schedule against a
|
||||||
|
|||||||
@@ -26,9 +26,18 @@ import java.time.format.FormatStyle
|
|||||||
* two it was is said by the words and the colour.
|
* two it was is said by the words and the colour.
|
||||||
*
|
*
|
||||||
* The rules take [color] too, so the whole divider reads as one mark of one kind.
|
* The rules take [color] too, so the whole divider reads as one mark of one kind.
|
||||||
|
*
|
||||||
|
* [trailing] is drawn inside the rules, beside the words -- the one divider that opens onto
|
||||||
|
* something needs its chevron there, and giving it its own copy of this layout is how the two would
|
||||||
|
* come to sit at different heights.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun TranscriptDivider(text: String, color: Color, modifier: Modifier = Modifier) {
|
fun TranscriptDivider(
|
||||||
|
text: String,
|
||||||
|
color: Color,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
trailing: (@Composable () -> Unit)? = null,
|
||||||
|
) {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
@@ -36,6 +45,7 @@ fun TranscriptDivider(text: String, color: Color, modifier: Modifier = Modifier)
|
|||||||
) {
|
) {
|
||||||
HorizontalDivider(Modifier.weight(1f), color = color)
|
HorizontalDivider(Modifier.weight(1f), color = color)
|
||||||
Text(text, style = MaterialTheme.typography.bodySmall, color = color)
|
Text(text, style = MaterialTheme.typography.bodySmall, color = color)
|
||||||
|
trailing?.invoke()
|
||||||
HorizontalDivider(Modifier.weight(1f), color = color)
|
HorizontalDivider(Modifier.weight(1f), color = color)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -288,6 +288,10 @@ fun SessionScreen(
|
|||||||
// Which messages from other agents are open, by the seq that identifies their row. Closed by
|
// Which messages from other agents are open, by the seq that identifies their row. Closed by
|
||||||
// default, which is the rule for anything new in this transcript.
|
// default, which is the rule for anything new in this transcript.
|
||||||
var expandedNotes by remember { mutableStateOf(setOf<Long>()) }
|
var expandedNotes by remember { mutableStateOf(setOf<Long>()) }
|
||||||
|
// Which task reports are open, by the seq that identifies their row. Closed by default, which
|
||||||
|
// is the rule for anything new in this transcript -- and doubly so here, since what one opens
|
||||||
|
// onto is already in the subagent's own transcript.
|
||||||
|
var expandedTaskNotes by remember { mutableStateOf(setOf<Long>()) }
|
||||||
// Which memory notes are open, by the note's own text. Held here rather than in the card so a
|
// Which memory notes are open, by the note's own text. Held here rather than in the card so a
|
||||||
// note opened and scrolled past is still open on the way back.
|
// note opened and scrolled past is still open on the way back.
|
||||||
var openMemories by remember { mutableStateOf(setOf<String>()) }
|
var openMemories by remember { mutableStateOf(setOf<String>()) }
|
||||||
@@ -1603,7 +1607,25 @@ fun SessionScreen(
|
|||||||
is TranscriptItem.CompactedNote ->
|
is TranscriptItem.CompactedNote ->
|
||||||
CompactedRow(item)
|
CompactedRow(item)
|
||||||
is TranscriptItem.LimitNote -> LimitRow(item)
|
is TranscriptItem.LimitNote -> LimitRow(item)
|
||||||
is TranscriptItem.TaskNote -> TaskNoteRow(item)
|
is TranscriptItem.TaskNote ->
|
||||||
|
TaskNoteRow(
|
||||||
|
item,
|
||||||
|
open = item.seq in expandedTaskNotes,
|
||||||
|
// Anchored, so the edge the reader pressed
|
||||||
|
// stays where it was.
|
||||||
|
onToggle = {
|
||||||
|
toggleAnchored(row) {
|
||||||
|
expandedTaskNotes =
|
||||||
|
if (
|
||||||
|
item.seq in
|
||||||
|
expandedTaskNotes
|
||||||
|
)
|
||||||
|
expandedTaskNotes - item.seq
|
||||||
|
else
|
||||||
|
expandedTaskNotes + item.seq
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
// Never reached: a peer message is flattened into
|
// Never reached: a peer message is flattened into
|
||||||
// its own units. Here because a `when` over the
|
// its own units. Here because a `when` over the
|
||||||
// item kinds has to stay exhaustive.
|
// item kinds has to stay exhaustive.
|
||||||
|
|||||||
@@ -1,89 +1,110 @@
|
|||||||
package com.example.aiapp
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material3.CardDefaults
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A background task reporting back: a subagent that finished, or a backgrounded command.
|
* The mark a background task reporting back leaves: a subagent that finished, or a backgrounded
|
||||||
|
* command.
|
||||||
*
|
*
|
||||||
* A card rather than a divider, and for the same reason a peer message is one -- somebody said
|
* A divider, drawn like a clear or a compaction, because what it marks is a *boundary*. The turn
|
||||||
* this. A divider is a fact about the conversation ("everything above is out of context"); this is
|
* below it is the session answering something that arrived, and without a row there the reply that
|
||||||
* a message that arrived, and the turn under it is the session answering it.
|
* ended the previous turn and the reply that answers this one met with nothing between them -- the
|
||||||
|
* fold grew the older message and drew two answers as one paragraph, running together mid-sentence.
|
||||||
*
|
*
|
||||||
* The card is also what separates the two turns. Before it existed, a reply woken by one of these
|
* **Closed, and the subagent's words are not what it says.** A subagent's closing report is
|
||||||
* met the previous reply with nothing between them and the transcript ran them into one paragraph,
|
* recorded as its own transcript's closing text, which is where somebody who wants it looks;
|
||||||
* mid-sentence. The row being *there* is most of the fix; what it says is the rest.
|
* putting it in the parent by default is the same paragraph in two places for a reader who did not
|
||||||
*
|
* ask for it. Opened, this shows it anyway, because having to leave the conversation to read one
|
||||||
* Drawn whole rather than in [cardPiece] slices, unlike a peer message: a summary is one sentence
|
* line is its own cost -- and because a backgrounded *command* has no transcript of its own, so
|
||||||
* the CLI wrote, so there is no unbounded case to bound. If one ever arrives long enough to be
|
* here is the only place its report exists at all.
|
||||||
* worth splitting, it belongs in the same flatten a peer message goes through.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun TaskNoteRow(item: TranscriptItem.TaskNote, modifier: Modifier = Modifier) {
|
fun TaskNoteRow(
|
||||||
Column(
|
item: TranscriptItem.TaskNote,
|
||||||
modifier.cardPiece(
|
open: Boolean,
|
||||||
top = true,
|
onToggle: () -> Unit,
|
||||||
bottom = true,
|
modifier: Modifier = Modifier,
|
||||||
fill = CardDefaults.cardColors().containerColor,
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
Text(
|
// Blue is the session's own background work -- the same thing `waiting` means in the status
|
||||||
taskNoteHeading(item.title, item.status),
|
// row, so "this is the session getting on with something it started" is learned once. Red only
|
||||||
style = MaterialTheme.typography.titleSmall,
|
// where something actually went wrong; a cancelled task is a choice somebody made.
|
||||||
color = taskNoteColor(item.status),
|
val colour = if (item.status == "failed") failedColor else waitingColor
|
||||||
|
val line = taskNoteSummary(item.title, item.status, item.summary)
|
||||||
|
// Nothing behind the line: a task that ended without a word, or one whose report *is* the line
|
||||||
|
// already. A control that opens onto nothing, or onto a copy of what is above it, teaches the
|
||||||
|
// reader that the control means nothing.
|
||||||
|
val expandable = item.summary != null && item.summary != line
|
||||||
|
Column(
|
||||||
|
modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.then(if (expandable) Modifier.clickable(onClick = onToggle) else Modifier)
|
||||||
|
) {
|
||||||
|
TranscriptDivider(
|
||||||
|
line,
|
||||||
|
colour,
|
||||||
|
trailing =
|
||||||
|
if (!expandable) null
|
||||||
|
else {
|
||||||
|
{
|
||||||
|
Chevron(
|
||||||
|
if (open) Pointing.Up else Pointing.Down,
|
||||||
|
colour = colour,
|
||||||
|
// The row is the control and the chevron is all of its marking, so the
|
||||||
|
// name belongs here: it is the only thing a screen reader has to read.
|
||||||
|
modifier =
|
||||||
|
Modifier.semantics {
|
||||||
|
contentDescription =
|
||||||
|
if (open) "Hide the report" else "Show the report"
|
||||||
|
},
|
||||||
)
|
)
|
||||||
if (item.summary != null) {
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (open && expandable) {
|
||||||
|
// Not the divider's colour: this is what the task said rather than a mark we drew, and
|
||||||
|
// colouring a quotation as if it were part of the rule around it makes the rule look
|
||||||
|
// like it is carrying some of the meaning.
|
||||||
Text(
|
Text(
|
||||||
item.summary,
|
item.summary.orEmpty(),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
modifier = Modifier.padding(top = 4.dp),
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(bottom = 8.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What the card says: who reported, and how it went.
|
* What the divider says: what reported, and how it went.
|
||||||
*
|
*
|
||||||
* Its own function so the wording is testable without a screen, and because the case that decides
|
* Its own function so the wording is testable without a screen, and because the case that decides
|
||||||
* whether this is any good is the one nobody builds a screen for -- a task that failed or was
|
* whether this is any good is the one nobody builds a screen for -- a task that failed or was
|
||||||
* killed. "Message from" is the right sentence for exactly one of the endings; using it for all of
|
* killed. "Reported back" is the right phrase for exactly one of the endings; using it for all of
|
||||||
* them would report a task that died as one that had something to say.
|
* them would announce a task that died as one that had something to say.
|
||||||
*
|
*
|
||||||
* A status word this build has never seen is said as itself rather than mapped onto the nearest
|
* A status word this build has never seen is said as itself rather than mapped onto the nearest
|
||||||
* one, since the nearest one would read as a decision somebody made.
|
* one, since the nearest one would read as a fact somebody established.
|
||||||
*/
|
*/
|
||||||
fun taskNoteHeading(title: String?, status: String): String {
|
fun taskNoteSummary(title: String?, status: String, summary: String?): String {
|
||||||
// A backgrounded command has no title of its own -- its summary names it -- so the card says
|
// A backgrounded command has no title of its own and its summary is already a whole sentence --
|
||||||
// what it was rather than inventing a name for it.
|
// `Background command "..." completed (exit code 0)`. Naming it from that beats "a background
|
||||||
val who = title ?: "a background task"
|
// task", which says nothing, and there is nothing left behind the line to open.
|
||||||
|
if (title == null && summary != null && status == "completed") return summary
|
||||||
|
val who = title ?: "A background task"
|
||||||
return when (status) {
|
return when (status) {
|
||||||
"completed" -> "Message from $who"
|
"completed" -> "$who reported back"
|
||||||
"failed" -> "$who failed"
|
"failed" -> "$who failed"
|
||||||
"cancelled" -> "$who was cancelled"
|
"cancelled" -> "$who was cancelled"
|
||||||
else -> "$who: $status"
|
else -> "$who: $status"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The heading's colour: coloured only where something went wrong.
|
|
||||||
*
|
|
||||||
* A task that finished and said something is the ordinary case and takes the ordinary text colour;
|
|
||||||
* the accent is spent on the one ending a reader would want to find by scanning. Cancelled is
|
|
||||||
* neither -- somebody chose it, and a deliberate choice is not a problem to report -- and a word
|
|
||||||
* this build does not recognise is not coloured as a failure, because it is not one. It says
|
|
||||||
* itself, which is the difference in *kind* that no colour can carry.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun taskNoteColor(status: String): Color =
|
|
||||||
when (status) {
|
|
||||||
"failed" -> failedColor
|
|
||||||
else -> MaterialTheme.colorScheme.onSurface
|
|
||||||
}
|
|
||||||
@@ -83,14 +83,21 @@ class TranscriptItemsTest {
|
|||||||
assertEquals(listOf("One turn.", "The next."), texts(whole))
|
assertEquals(listOf("One turn.", "The next."), texts(whole))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The endings nobody builds a screen for -- see [taskNoteHeading]. */
|
/** The endings nobody builds a screen for -- see [taskNoteSummary]. */
|
||||||
@Test
|
@Test
|
||||||
fun a_task_note_says_which_ending_it_was() {
|
fun a_task_note_says_which_ending_it_was() {
|
||||||
assertEquals("Message from helper 1", taskNoteHeading("helper 1", "completed"))
|
val said = "it said hello"
|
||||||
assertEquals("Message from a background task", taskNoteHeading(null, "completed"))
|
// A subagent is named, and its own words stay in its own transcript rather than being
|
||||||
assertEquals("helper 1 failed", taskNoteHeading("helper 1", "failed"))
|
// repeated here.
|
||||||
assertEquals("helper 1 was cancelled", taskNoteHeading("helper 1", "cancelled"))
|
assertEquals("helper 1 reported back", taskNoteSummary("helper 1", "completed", said))
|
||||||
|
assertEquals("helper 1 failed", taskNoteSummary("helper 1", "failed", null))
|
||||||
|
assertEquals("helper 1 was cancelled", taskNoteSummary("helper 1", "cancelled", said))
|
||||||
// A word this build has never seen is said as itself, not mapped onto the nearest one.
|
// A word this build has never seen is said as itself, not mapped onto the nearest one.
|
||||||
assertEquals("helper 1: evicted", taskNoteHeading("helper 1", "evicted"))
|
assertEquals("helper 1: evicted", taskNoteSummary("helper 1", "evicted", said))
|
||||||
|
// A backgrounded command has no title and no transcript of its own, so its summary is the
|
||||||
|
// only record of it there is -- and it is already a sentence.
|
||||||
|
val command = """Background command "build the kernel" completed (exit code 0)"""
|
||||||
|
assertEquals(command, taskNoteSummary(null, "completed", command))
|
||||||
|
assertEquals("A background task failed", taskNoteSummary(null, "failed", null))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
//! session directory; everything else is pure, which is what makes the mapping
|
//! session directory; everything else is pure, which is what makes the mapping
|
||||||
//! testable without a process.
|
//! testable without a process.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
@@ -108,19 +108,23 @@ pub(super) struct Translator {
|
|||||||
/// that only the change into that state is reported -- see
|
/// that only the change into that state is reported -- see
|
||||||
/// [`Translator::translate_rate_limit`].
|
/// [`Translator::translate_rate_limit`].
|
||||||
rate_limited: bool,
|
rate_limited: bool,
|
||||||
/// Which Task call each *unfinished* task belongs to: the CLI's `task_id`
|
/// Which Task call each task belongs to: the CLI's `task_id` against the
|
||||||
/// against the `tool_use_id` this side names a subagent by.
|
/// `tool_use_id` this side names a subagent by.
|
||||||
///
|
///
|
||||||
/// Needed because the line that says a task ended comes in two shapes and
|
/// Needed because the line that says a task ended comes in two shapes and
|
||||||
/// only one of them carries the tool id -- see [`Translator::translate_task`].
|
/// only one of them carries the tool id -- see [`Translator::translate_task`].
|
||||||
///
|
|
||||||
/// Emptied entry by entry as tasks report back, which makes it the answer
|
|
||||||
/// to two further questions: whether an ending has already been reported
|
|
||||||
/// (the two shapes can both arrive for one task, and the row belongs in
|
|
||||||
/// the transcript once), and whether the session still has work outstanding
|
|
||||||
/// when its own turn ends, which is the difference between `Idle` and
|
|
||||||
/// [`SessionStatus::Waiting`].
|
|
||||||
tasks: HashMap<String, String>,
|
tasks: HashMap<String, String>,
|
||||||
|
/// The backgrounded tasks this translator has seen start and not seen
|
||||||
|
/// finish, by `tool_use_id`.
|
||||||
|
///
|
||||||
|
/// Half of the answer to "does this session still have work outstanding",
|
||||||
|
/// which is the difference between `Idle` and [`SessionStatus::Waiting`].
|
||||||
|
/// The other half is `Subagents::any_open`, and both are needed: this one
|
||||||
|
/// covers a backgrounded *command*, which has no subagent behind it at
|
||||||
|
/// all, and the registry covers a subagent launched before this
|
||||||
|
/// translator existed, which is every one of them after a backend
|
||||||
|
/// restart adopts a running session.
|
||||||
|
open_tasks: HashSet<String>,
|
||||||
/// Whether a turn is open, judged from this translator's own output: the
|
/// Whether a turn is open, judged from this translator's own output: the
|
||||||
/// events that [`super::proves_a_turn`] accepts open one, and the status
|
/// events that [`super::proves_a_turn`] accepts open one, and the status
|
||||||
/// that ends a turn closes it.
|
/// that ends a turn closes it.
|
||||||
@@ -146,6 +150,7 @@ impl Translator {
|
|||||||
children: HashMap::new(),
|
children: HashMap::new(),
|
||||||
rate_limited: false,
|
rate_limited: false,
|
||||||
tasks: HashMap::new(),
|
tasks: HashMap::new(),
|
||||||
|
open_tasks: HashSet::new(),
|
||||||
in_turn: false,
|
in_turn: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -374,10 +379,10 @@ impl Translator {
|
|||||||
// nobody having typed anything. Reported as what it is, so
|
// nobody having typed anything. Reported as what it is, so
|
||||||
// that nothing tells the reader the work has finished.
|
// that nothing tells the reader the work has finished.
|
||||||
events.push(Event::Status {
|
events.push(Event::Status {
|
||||||
state: if self.tasks.is_empty() {
|
state: if self.work_outstanding() {
|
||||||
SessionStatus::Idle
|
|
||||||
} else {
|
|
||||||
SessionStatus::Waiting
|
SessionStatus::Waiting
|
||||||
|
} else {
|
||||||
|
SessionStatus::Idle
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
events
|
events
|
||||||
@@ -534,7 +539,6 @@ impl Translator {
|
|||||||
.or_else(|| task_id.and_then(|task| self.tasks.get(task).cloned()));
|
.or_else(|| task_id.and_then(|task| self.tasks.get(task).cloned()));
|
||||||
match message.get("subtype").and_then(Value::as_str) {
|
match message.get("subtype").and_then(Value::as_str) {
|
||||||
Some("task_notification") => self.task_ended(
|
Some("task_notification") => self.task_ended(
|
||||||
task_id,
|
|
||||||
about,
|
about,
|
||||||
message.get("status").and_then(Value::as_str),
|
message.get("status").and_then(Value::as_str),
|
||||||
text_field(message, "summary"),
|
text_field(message, "summary"),
|
||||||
@@ -555,16 +559,39 @@ impl Translator {
|
|||||||
if status == Some("completed") {
|
if status == Some("completed") {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
self.task_ended(task_id, about, status, None)
|
self.task_ended(about, status, None)
|
||||||
}
|
}
|
||||||
// `task_started` and `task_progress`: the mapping above is the
|
Some("task_started") => {
|
||||||
// whole of what they are for. The subagent itself is created by
|
// What makes the session `Waiting` when its turn ends. The
|
||||||
// the Task `tool_use` in the parent's own message, which arrives
|
// subagent itself is created by the Task `tool_use` in the
|
||||||
// first and carries the title this side shows.
|
// parent's own message, which arrives first and carries the
|
||||||
|
// title this side shows.
|
||||||
|
if let Some(about) = about {
|
||||||
|
self.open_tasks.insert(about);
|
||||||
|
}
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
// `task_progress`: the mapping above is the whole of what it is
|
||||||
|
// for.
|
||||||
_ => Vec::new(),
|
_ => Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the session has work of its own still running: the difference
|
||||||
|
/// between `Idle` and [`SessionStatus::Waiting`].
|
||||||
|
///
|
||||||
|
/// Two sources because neither covers the other. `open_tasks` holds what
|
||||||
|
/// this translator watched start, which is the only thing that knows
|
||||||
|
/// about a backgrounded *command* -- it has no subagent. The registry
|
||||||
|
/// holds what is on disk, which is the only thing that knows about a
|
||||||
|
/// subagent that started before this translator did.
|
||||||
|
///
|
||||||
|
/// `session_running` is true by construction: this is only ever asked
|
||||||
|
/// while translating a line the session's process just wrote.
|
||||||
|
fn work_outstanding(&self) -> bool {
|
||||||
|
!self.open_tasks.is_empty() || self.subagents.any_open(true)
|
||||||
|
}
|
||||||
|
|
||||||
/// A task reporting back, from whichever of the two lines got here first.
|
/// A task reporting back, from whichever of the two lines got here first.
|
||||||
///
|
///
|
||||||
/// Reported once. The two shapes can both arrive for one task, and the
|
/// Reported once. The two shapes can both arrive for one task, and the
|
||||||
@@ -582,7 +609,6 @@ impl Translator {
|
|||||||
/// of it.
|
/// of it.
|
||||||
fn task_ended(
|
fn task_ended(
|
||||||
&mut self,
|
&mut self,
|
||||||
task_id: Option<&str>,
|
|
||||||
about: Option<String>,
|
about: Option<String>,
|
||||||
status: Option<&str>,
|
status: Option<&str>,
|
||||||
summary: Option<String>,
|
summary: Option<String>,
|
||||||
@@ -593,11 +619,16 @@ impl Translator {
|
|||||||
let Some(about) = about else {
|
let Some(about) = about else {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
};
|
};
|
||||||
// Nothing under that id: either this task has already been reported,
|
// Reported once. The two lifecycle shapes can both arrive for one
|
||||||
// or its `task_started` was never seen. Both are "say nothing"; the
|
// task, and whichever gets here first is the one that finds it open.
|
||||||
// first would be a duplicate row and the second a row for a task this
|
//
|
||||||
// translator cannot say anything about.
|
// The registry is asked as well as this translator's own set, and
|
||||||
if task_id.is_none_or(|task| self.tasks.remove(task).is_none()) {
|
// that is what makes an adopted session work: a subagent launched
|
||||||
|
// before a backend restart has no entry here, because its
|
||||||
|
// `task_started` is behind the offset its session's stdout is read
|
||||||
|
// from. `finish` below closes it either way, so a second line for the
|
||||||
|
// same task still finds nothing.
|
||||||
|
if !self.open_tasks.remove(&about) && !self.subagents.is_open(&about) {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
if let Some(summary) = &summary {
|
if let Some(summary) = &summary {
|
||||||
@@ -621,7 +652,7 @@ impl Translator {
|
|||||||
// over: it has stopped being `Waiting` and nothing else will say so.
|
// over: it has stopped being `Waiting` and nothing else will say so.
|
||||||
// Inside a turn there is nothing to announce -- the turn's own
|
// Inside a turn there is nothing to announce -- the turn's own
|
||||||
// `result` will decide between the two statuses when it lands.
|
// `result` will decide between the two statuses when it lands.
|
||||||
if self.tasks.is_empty() && !self.in_turn {
|
if !self.work_outstanding() && !self.in_turn {
|
||||||
events.push(Event::Status {
|
events.push(Event::Status {
|
||||||
state: SessionStatus::Idle,
|
state: SessionStatus::Idle,
|
||||||
});
|
});
|
||||||
@@ -1476,6 +1507,62 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The case a backend restart produces, which is every subagent a session
|
||||||
|
/// has when the server is updated under it. Adoption picks the session's
|
||||||
|
/// stdout back up from a recorded offset, so the `task_started` lines for
|
||||||
|
/// anything already running are behind it and this translator never sees
|
||||||
|
/// them: it starts empty, and asking only itself would report the session
|
||||||
|
/// idle with a subagent plainly still working.
|
||||||
|
#[test]
|
||||||
|
fn a_subagent_that_started_before_this_translator_still_counts_as_outstanding() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let subagents = test_subagents(&dir);
|
||||||
|
// Started by somebody else, exactly as a previous run of the server
|
||||||
|
// would have left it on disk.
|
||||||
|
subagents.start("toolu_old", "the Dev Updater agent", None);
|
||||||
|
|
||||||
|
let mut translator = Translator::new(dir.path().to_path_buf(), Arc::clone(&subagents));
|
||||||
|
let result = r#"{"type":"result","subtype":"success","is_error":false,"usage":{}}"#;
|
||||||
|
assert_eq!(
|
||||||
|
translate_lines(&mut translator, &[result]).last(),
|
||||||
|
Some(&Event::Status {
|
||||||
|
state: SessionStatus::Waiting
|
||||||
|
}),
|
||||||
|
"the registry knows about it even though this translator does not"
|
||||||
|
);
|
||||||
|
|
||||||
|
// And its ending is reported, though nothing here saw it begin.
|
||||||
|
assert_eq!(
|
||||||
|
translate_lines(
|
||||||
|
&mut translator,
|
||||||
|
&[
|
||||||
|
r#"{"type":"system","subtype":"task_notification","task_id":"old","tool_use_id":"toolu_old","status":"completed","summary":"pushed"}"#,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
vec![
|
||||||
|
Event::TaskNote {
|
||||||
|
about: "toolu_old".into(),
|
||||||
|
title: Some("the Dev Updater agent".into()),
|
||||||
|
status: "completed".into(),
|
||||||
|
summary: Some("pushed".into()),
|
||||||
|
},
|
||||||
|
Event::Status {
|
||||||
|
state: SessionStatus::Idle
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
// Once: `finish` closed it, so the second shape finds nothing.
|
||||||
|
assert!(
|
||||||
|
translate_lines(
|
||||||
|
&mut translator,
|
||||||
|
&[
|
||||||
|
r#"{"type":"system","subtype":"task_updated","task_id":"old","patch":{"status":"failed"}}"#,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// A task ending *inside* a turn says nothing about the session's status:
|
/// A task ending *inside* a turn says nothing about the session's status:
|
||||||
/// the turn is still running, and its own `result` decides. Without the
|
/// the turn is still running, and its own `result` decides. Without the
|
||||||
/// `in_turn` guard this reported the session idle in the middle of one,
|
/// `in_turn` guard this reported the session idle in the middle of one,
|
||||||
|
|||||||
@@ -211,11 +211,15 @@ pub enum Event {
|
|||||||
/// A task the session started in the background reporting back: a
|
/// A task the session started in the background reporting back: a
|
||||||
/// subagent that has finished, or a backgrounded command.
|
/// subagent that has finished, or a backgrounded command.
|
||||||
///
|
///
|
||||||
/// Recorded because it is a message the session *received*, and without
|
/// Recorded because the turn the session wakes up and runs would
|
||||||
/// it the turn it wakes up and runs has nothing in front of it. Two
|
/// otherwise have nothing in front of it: two replies met with no row
|
||||||
/// replies then met with no row between them and were folded into one,
|
/// between them and were folded into one, so a phone drew the answer to
|
||||||
/// so a phone drew the answer to a question nobody could see as a
|
/// a question nobody could see as a continuation of the previous
|
||||||
/// continuation of the previous sentence.
|
/// sentence. It is drawn as a **divider** rather than as a message --
|
||||||
|
/// what it marks is the boundary, and the subagent's own words are in
|
||||||
|
/// the subagent's own transcript, which is where somebody who wants them
|
||||||
|
/// looks. Repeating them here would be the same text in two places, and
|
||||||
|
/// the copy is the one that goes stale.
|
||||||
///
|
///
|
||||||
/// Its own kind rather than an update to the Task call's row: that row
|
/// Its own kind rather than an update to the Task call's row: that row
|
||||||
/// is wherever the call was made, which is above everything the session
|
/// is wherever the call was made, which is above everything the session
|
||||||
@@ -236,6 +240,11 @@ pub enum Event {
|
|||||||
/// "finished" is the wrong word for a task that was killed.
|
/// "finished" is the wrong word for a task that was killed.
|
||||||
status: String,
|
status: String,
|
||||||
/// What it said on the way out, where it said anything.
|
/// What it said on the way out, where it said anything.
|
||||||
|
///
|
||||||
|
/// Only ever *shown* for a task with no [`TaskNote::title`], which is
|
||||||
|
/// a backgrounded command: it has no transcript of its own, so this
|
||||||
|
/// is the only record there is of it. A subagent's report is recorded
|
||||||
|
/// as that subagent's own closing text and is not repeated here.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
summary: Option<String>,
|
summary: Option<String>,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -944,15 +944,24 @@ async fn run_helper(
|
|||||||
if elapsed < target {
|
if elapsed < target {
|
||||||
tokio::time::sleep(target - elapsed).await;
|
tokio::time::sleep(target - elapsed).await;
|
||||||
}
|
}
|
||||||
|
// The subagent's closing report, in the subagent's own transcript, which
|
||||||
|
// is where a real one's goes and the only place it belongs -- recorded
|
||||||
|
// before the ending, so it is not below it.
|
||||||
|
let summary = format!("{title} finished and had nothing to report.");
|
||||||
|
subagents.record(
|
||||||
|
&id,
|
||||||
|
Event::AssistantText {
|
||||||
|
delta: summary.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
subagents.finish(&id);
|
subagents.finish(&id);
|
||||||
let _ = sink.send(Event::ToolEnd {
|
let _ = sink.send(Event::ToolEnd {
|
||||||
id: id.clone(),
|
id: id.clone(),
|
||||||
output: "subagent finished".to_string(),
|
output: "subagent finished".to_string(),
|
||||||
});
|
});
|
||||||
// The message the session receives, and then the turn it runs because of
|
// The boundary the session's next turn begins at, and then that turn: the
|
||||||
// it: the parent has to say something afterwards, since the defect this
|
// parent has to say something afterwards, since the defect this
|
||||||
// reproduces is two replies meeting with nothing between them.
|
// reproduces is two replies meeting with nothing between them.
|
||||||
let summary = format!("{title} finished and had nothing to report.");
|
|
||||||
let _ = sink.send(Event::TaskNote {
|
let _ = sink.send(Event::TaskNote {
|
||||||
about: id,
|
about: id,
|
||||||
title: Some(title.clone()),
|
title: Some(title.clone()),
|
||||||
|
|||||||
@@ -280,6 +280,35 @@ impl Subagents {
|
|||||||
.filter(|title| !title.is_empty())
|
.filter(|title| !title.is_empty())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the subagent named `id` exists and has not finished. `false`
|
||||||
|
/// for an id that is not a subagent's at all -- a backgrounded command's
|
||||||
|
/// tool call reaches here with the same shape.
|
||||||
|
pub fn is_open(&self, id: &str) -> bool {
|
||||||
|
self.get(id).is_some_and(|subagent| subagent.is_open())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this session has any subagent still working, read from the
|
||||||
|
/// directory rather than from what this process has seen.
|
||||||
|
///
|
||||||
|
/// That is the whole point of it. A backend restart adopts a session's
|
||||||
|
/// process and picks its stdout back up from a recorded offset, so the
|
||||||
|
/// `task_started` lines for subagents launched before the restart are
|
||||||
|
/// already behind that offset and the translator never sees them -- it
|
||||||
|
/// starts with an empty set and reports the session `Idle` at the end of
|
||||||
|
/// a turn it should have called [`SessionStatus::Waiting`]. Asking the
|
||||||
|
/// registry is a measurement instead of bookkeeping, so it is right for
|
||||||
|
/// a session this process did not start.
|
||||||
|
///
|
||||||
|
/// Measured rather than cached because the wrong answer has to be able
|
||||||
|
/// to correct itself: a subagent left `Running` by a previous run is
|
||||||
|
/// finished by the session's own exit (see `finish_all`), and the next
|
||||||
|
/// turn to end then reads the truth.
|
||||||
|
pub fn any_open(&self, session_running: bool) -> bool {
|
||||||
|
self.list(session_running)
|
||||||
|
.iter()
|
||||||
|
.any(|info| info.status == SessionStatus::Running)
|
||||||
|
}
|
||||||
|
|
||||||
/// Appends one event to a subagent's own transcript. A no-op, with a
|
/// Appends one event to a subagent's own transcript. A no-op, with a
|
||||||
/// debug log, for an id nothing was started under -- a child line for a
|
/// debug log, for an id nothing was started under -- a child line for a
|
||||||
/// subagent this registry never opened is dropped rather than guessed
|
/// subagent this registry never opened is dropped rather than guessed
|
||||||
|
|||||||
Reference in new issue
Block a user