Reconcile Claude background task state

This commit is contained in:
iris-ai committed 2026-09-15 12:49:22 -04:00
1 parent f0661919bb
commit 9fd21af4e8
6 files changed
+310 -24

No files matched your search

+9 -7
View File
@@ -261,13 +261,15 @@ written, and the fold uses that same predicate to decide a reply is settled.
mid-sentence. `./ui-sandbox.sh` plus `/subagent 3` or `/background 5` in an
echo session is the whole rig; the helpers stagger a second apart so each
reply is its own.
- **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.
- **Claude's background-task level is authority; two edge sources are the
fallback.** Since Claude Code 2.1.261,
`background_tasks_changed { tasks: [...] }` replaces the live set and repairs
a missed ending edge. An adopted CLI is sent a repeated `initialize` to ask
for the current set. Reconcile only between turns or at a result boundary:
a foreground agent is legitimately absent from a background-only snapshot.
Older CLIs still need both edge sources: `open_tasks` knows about a
backgrounded command, while `Subagents::any_open` finds a subagent whose
`task_started` is behind an adopted stdout offset.
- **A usage limit a subagent hits reaches the session**, not just the
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
+13
View File
@@ -870,6 +870,19 @@ rather than left making a claim nothing will ever correct — including for the
endings that carry no summary, which are exactly the ones that went wrong and
the ones a stale "running in background" reads worst on.
**The edge stream is detail; Claude's background-task level is authority**
(2026-09-15). Claude Code 2.1.261 added
`background_tasks_changed { tasks: [...] }` with replace semantics expressly so
a missed bookend cannot wedge a running indicator. Its ids are not correlated
with the edge stream; this side uses the authoritative empty/nonempty level.
The driver sends a repeated `initialize` when it adopts a CLI, which prompts a
full snapshot without restarting the conversation. A parent already recorded as idle or waiting can
apply it immediately; one adopted mid-turn waits for the result boundary,
because a foreground agent is correctly absent from a background-only set.
The ordinary notifications still supply outcomes and summaries, including when
one is ordered after the level already corrected the status. Older CLIs send no
level and retain the edge fallback below.
What the notification is still used for is the status: it is what closes a task
in `Status::Waiting`'s bookkeeping. Handled once, however many of the two
lifecycle shapes (`task_notification`, `task_updated`) arrive — whichever gets
+20 -6
View File
@@ -86,6 +86,20 @@ transcript is still being written to and its process is the session's to stop.
status ends it from the update, since the failure to avoid is a subagent
nothing ever finishes.
Since Claude Code 2.1.261, `background_tasks_changed { tasks: [...] }` is
the authoritative level beside those edges: its set replaces the previous
set, so a missed terminal edge cannot leave a subagent running forever. Its
ids are deliberately not correlated with the edge stream; the useful claim
here is whether the set is empty. The edges still carry mapping, outcome
and closing summary. On adoption the driver sends a repeated `initialize`,
which makes a current CLI send the full set; an older CLI accepts it and sends no level,
leaving the edge-based path unchanged. A snapshot is reconciled immediately
when the persisted parent status proves it is between turns, and otherwise
at the next `result` boundary -- while a turn is open, a foreground agent is
legitimately absent from the background set. Reconciliation writes
`Status Exited`, which is also what makes a formerly stale row deletable;
a task notification ordered after the level can still add its summary.
The two rules this replaces were both wrong, in opposite directions. The
parent's `tool_result` is not it: a backgrounded Task's arrives at launch
("Async agent launched..."), so ending there truncated a running agent's
@@ -117,12 +131,12 @@ transcript is still being written to and its process is the session's to stop.
**While any task is outstanding the session's turn ends in
`Status Waiting` rather than `Idle`.** `Idle` means "waiting for a person",
and a session with a backgrounded subagent is not doing that. Two sources:
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. That
second lookup is also what lets such a subagent's ending be recognised at
all.
and a session with a backgrounded subagent is not doing that. The edge
fallback has two sources: the translator's `open_tasks`, and
`Subagents::any_open` -- which covers a subagent launched before a backend
restart adopted the session, whose `task_started` is behind the durable
stdout offset. On current Claude versions the replace-semantics level above
reconciles both at a safe turn boundary.
**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
+18 -2
View File
@@ -214,6 +214,7 @@ impl ClaudeDriver {
session_dir: &Path,
sink: EventSink,
subagents: Arc<Subagents>,
initial_status: SessionStatus,
) -> Result<Self> {
let state = Arc::new(Mutex::new(Translator::new(
session_dir.to_path_buf(),
@@ -261,6 +262,9 @@ impl ClaudeDriver {
Self::start(meta, provider, transport, session_dir)?
}
};
if !started_here && matches!(initial_status, SessionStatus::Idle | SessionStatus::Waiting) {
state.lock().unwrap().mark_settled();
}
// A process this driver has just started has been asked for nothing,
// which is what idle means. Said here because nothing else will: the
@@ -320,14 +324,22 @@ impl ClaudeDriver {
format!("{} {}", provider.name, transport.describe()),
));
Ok(Self {
let driver = Self {
sink,
queue,
to_child,
state,
session_dir: session_dir.to_path_buf(),
reading,
})
};
if !started_here {
// Claude 2.1.261 sends a full background-task snapshot after a
// repeated initialize. That repairs a completion edge missed by a
// backend that was down while the CLI kept running; older CLIs
// accept the request and simply send no snapshot.
driver.send_control(json!({"subtype": "initialize"}), None);
}
Ok(driver)
}
/// Starts a new CLI for this session, with its streams in the session
@@ -866,8 +878,12 @@ fn translate_line(
return true;
};
let opens_a_model_call = starts_a_model_call(&message);
let parent_running = queue.lock().unwrap().running;
let (events, new_session_id, before) = {
let mut state = state.lock().unwrap();
if parent_running {
state.mark_running();
}
let before = state.session_id.clone();
let events = state.translate(&message);
let after = state.session_id.clone();
+246 -9
View File
@@ -63,6 +63,14 @@ struct PendingRequest {
answers: HashMap<String, String>,
}
/// Where a terminal notification's detail still belongs after the level
/// signal has already closed the task.
#[derive(Clone, Copy, PartialEq)]
enum TaskReport {
Subagent,
Command,
}
/// Translation state: stream-json lines in, common events out.
pub(super) struct Translator {
pub(super) session_id: Option<String>,
@@ -125,6 +133,16 @@ pub(super) struct Translator {
/// translator existed, which is every one of them after a backend
/// restart adopts a running session.
open_tasks: HashSet<String>,
/// Claude's level signal for background work, when this CLI is new enough
/// to send one. Unlike `open_tasks`, this is a snapshot: each new value
/// replaces the old one, so a missed ending edge cannot leave work open
/// forever. See [`Translator::translate_background_tasks`].
background_tasks: Option<bool>,
/// Tasks the level signal closed before their ordinary notification
/// arrived. That notification still owns the useful summary, so it gets
/// one chance to update the transcript or tool card after the status was
/// already corrected.
awaiting_task_summaries: HashMap<String, TaskReport>,
/// File-edit calls whose successful boilerplate result should not be drawn below their diff.
/// Each leaves here with its `tool_result`; a failure keeps its text because that is the part a
/// reader needs to act on.
@@ -139,6 +157,10 @@ pub(super) struct Translator {
/// narrow: whether a task reporting back means the session has gone idle,
/// or is merely one of several things happening inside a turn.
in_turn: bool,
/// Whether this translator has observed that the parent is between turns.
/// False on construction because an adopted process may be mid-turn; the
/// driver sets it only when the persisted session status proves otherwise.
settled: bool,
}
impl Translator {
@@ -155,11 +177,28 @@ impl Translator {
rate_limited: false,
tasks: HashMap::new(),
open_tasks: HashSet::new(),
background_tasks: None,
awaiting_task_summaries: HashMap::new(),
patches: HashSet::new(),
in_turn: false,
settled: false,
}
}
/// The persisted parent status says an adopted process is between turns,
/// so an initial background-task snapshot is safe to apply immediately.
pub(super) fn mark_settled(&mut self) {
self.settled = true;
}
/// The driver has a parent turn in flight. This covers the interval before
/// its first output proves the same thing, when a background-task update
/// from an older turn must not briefly return the session to idle.
pub(super) fn mark_running(&mut self) {
self.in_turn = true;
self.settled = false;
}
/// Remembers what a control request was for, so its answer can say so.
/// Called before the request goes out: the reader thread is already running
/// and a fast CLI can answer before this side gets back to it.
@@ -263,8 +302,10 @@ impl Translator {
let events = self.translate_line(message);
if events.iter().any(closes_a_turn) {
self.in_turn = false;
self.settled = true;
} else if events.iter().any(super::proves_a_turn) {
self.in_turn = true;
self.settled = false;
}
events
}
@@ -388,6 +429,10 @@ impl Translator {
if tokens > 0 {
events.push(Event::UsageDelta { tokens, context });
}
// A level snapshot is authoritative at a turn boundary. In
// particular, it repairs a task whose terminal edge was
// missed before this backend adopted the still-running CLI.
events.extend(self.reconcile_background_tasks());
// Idle means "waiting for a person", and a session with a
// backgrounded subagent or command still running is not doing
// that -- it is waiting for itself, and will speak again with
@@ -450,6 +495,7 @@ impl Translator {
Some("task_started" | "task_progress" | "task_updated" | "task_notification") => {
self.translate_task(message)
}
Some("background_tasks_changed") => self.translate_background_tasks(message),
Some("compact_boundary") => {
let meta = &message["compact_metadata"];
vec![Event::Compacted {
@@ -582,6 +628,7 @@ impl Translator {
// parent's own message, which arrives first and carries the
// title this side shows.
if let Some(about) = about {
self.awaiting_task_summaries.remove(&about);
self.open_tasks.insert(about);
}
Vec::new()
@@ -592,19 +639,87 @@ impl Translator {
}
}
/// Claude 2.1.261's authoritative account of whether background work is
/// alive. The `tasks` array has replace semantics, but its ids are not
/// promised to correlate with task edges, so only its emptiness is used.
fn translate_background_tasks(&mut self, message: &Value) -> Vec<Event> {
let Some(tasks) = message.get("tasks").and_then(Value::as_array) else {
tracing::warn!("background_tasks_changed without a tasks array");
return Vec::new();
};
let was_outstanding = self.work_outstanding();
// The CLI explicitly says not to correlate these ids with its edge
// stream. Their useful claim here is the level: empty or not.
self.background_tasks = Some(!tasks.is_empty());
// During a turn the snapshot omits a foreground task, so wait for the
// result boundary before using it to close anything. Between turns,
// every task that can still be alive is background work and the level
// can repair a missed notification immediately.
if !self.settled || self.in_turn {
return Vec::new();
}
let mut events = self.reconcile_background_tasks();
let is_outstanding = self.work_outstanding();
if was_outstanding != is_outstanding {
events.push(Event::Status {
state: if is_outstanding {
SessionStatus::Waiting
} else {
SessionStatus::Idle
},
});
}
events
}
/// Applies the latest background-task snapshot at a point where no
/// foreground task can remain. Returns updates for background commands;
/// subagents carry the same correction in their own status transcript.
fn reconcile_background_tasks(&mut self) -> Vec<Event> {
let Some(false) = self.background_tasks else {
return Vec::new();
};
let mut events = Vec::new();
for info in self.subagents.list(true) {
if info.status == SessionStatus::Running {
self.awaiting_task_summaries
.insert(info.id.clone(), TaskReport::Subagent);
self.subagents.finish(&info.id);
}
}
for id in self.open_tasks.drain() {
let report = if self.subagents.get(&id).is_some() {
TaskReport::Subagent
} else {
TaskReport::Command
};
self.awaiting_task_summaries.insert(id.clone(), report);
if report == TaskReport::Command {
events.push(Event::ToolUpdate {
id,
output: "this background command finished".to_string(),
});
}
}
events
}
/// 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.
/// The level is authoritative when a current CLI has supplied it. The
/// edge fallback has two sources because neither covers the other:
/// `open_tasks` knows about a backgrounded command, which has no
/// subagent, while the registry knows about a subagent started before
/// this translator existed.
///
/// `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)
self.background_tasks == Some(true)
|| !self.open_tasks.is_empty()
|| self.subagents.any_open(true)
}
/// A task reporting back, from whichever of the two lines got here first.
@@ -643,7 +758,9 @@ impl Translator {
// `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) {
let reconciled = self.awaiting_task_summaries.remove(&about);
let was_open = self.open_tasks.remove(&about) || self.subagents.is_open(&about);
if !was_open && reconciled.is_none() {
return Vec::new();
}
let mut events = Vec::new();
@@ -661,7 +778,7 @@ impl Translator {
self.subagents
.record(&about, Event::AssistantText { delta: summary });
}
} else {
} else if reconciled != Some(TaskReport::Subagent) {
events.push(Event::ToolUpdate {
id: about.clone(),
// A task that stopped without a word still has to say so: the
@@ -677,7 +794,7 @@ impl Translator {
// over: it has stopped being `Waiting` and nothing else will say so.
// Inside a turn there is nothing to announce -- the turn's own
// `result` will decide between the two statuses when it lands.
if !self.work_outstanding() && !self.in_turn {
if was_open && !self.work_outstanding() && !self.in_turn {
events.push(Event::Status {
state: SessionStatus::Idle,
});
@@ -1724,6 +1841,126 @@ mod tests {
);
}
/// Claude's task edges are useful detail but not durable state. A
/// repeated initialize after adoption sends this level snapshot, whose
/// empty set is the authoritative answer even when the old registry says
/// a subagent is still running.
#[test]
fn a_background_snapshot_repairs_an_adopted_waiting_session() {
let dir = tempfile::tempdir().expect("tempdir");
let subagents = test_subagents(&dir);
subagents.start("toolu_stale", "an old helper", None);
let mut translator = Translator::new(dir.path().to_path_buf(), Arc::clone(&subagents));
translator.mark_settled();
assert!(
translate_lines(
&mut translator,
&[r#"{"type":"system","subtype":"background_tasks_changed","tasks":["toolu_stale"]}"#],
)
.is_empty()
);
assert_eq!(subagents.list(true)[0].status, SessionStatus::Running);
assert_eq!(
translate_lines(
&mut translator,
&[r#"{"type":"system","subtype":"background_tasks_changed","tasks":[]}"#],
),
vec![Event::Status {
state: SessionStatus::Idle
}]
);
assert_eq!(subagents.list(true)[0].status, SessionStatus::Exited);
}
/// An adopted process may be in the middle of a foreground agent when its
/// initialize snapshot arrives. Foreground work is absent from that
/// snapshot, so it is only safe to reconcile at the result boundary.
#[test]
fn a_background_snapshot_does_not_close_foreground_work_mid_turn() {
let dir = tempfile::tempdir().expect("tempdir");
let subagents = test_subagents(&dir);
subagents.start("toolu_foreground", "foreground helper", None);
let mut translator = Translator::new(dir.path().to_path_buf(), Arc::clone(&subagents));
translator.mark_running();
assert!(
translate_lines(
&mut translator,
&[r#"{"type":"system","subtype":"background_tasks_changed","tasks":[]}"#],
)
.is_empty()
);
assert_eq!(subagents.list(true)[0].status, SessionStatus::Running);
let result = r#"{"type":"result","subtype":"success","is_error":false,"usage":{}}"#;
assert_eq!(
translate_lines(&mut translator, &[result]).last(),
Some(&Event::Status {
state: SessionStatus::Idle
})
);
assert_eq!(subagents.list(true)[0].status, SessionStatus::Exited);
}
/// The level edge is deliberately allowed to arrive before the detailed
/// notification. Correcting the status must not discard the useful report
/// that follows it or emit a second idle transition.
#[test]
fn a_notification_after_the_level_correction_keeps_its_summary() {
let dir = tempfile::tempdir().expect("tempdir");
let subagents = test_subagents(&dir);
subagents.start("toolu_level", "level helper", None);
let mut translator = Translator::new(dir.path().to_path_buf(), Arc::clone(&subagents));
translator.mark_settled();
translate_lines(
&mut translator,
&[r#"{"type":"system","subtype":"background_tasks_changed","tasks":[]}"#],
);
assert!(
translate_lines(
&mut translator,
&[r#"{"type":"system","subtype":"task_notification","tool_use_id":"toolu_level","status":"completed","summary":"the useful report"}"#],
)
.is_empty()
);
let transcript_path = subagents
.get("toolu_level")
.expect("subagent")
.transcript_path();
assert!(
std::fs::read_to_string(transcript_path)
.expect("read transcript")
.contains("the useful report")
);
}
#[test]
fn a_deleted_reconciled_subagent_is_not_mistaken_for_a_command() {
let dir = tempfile::tempdir().expect("tempdir");
let subagents = test_subagents(&dir);
subagents.start("toolu_deleted", "deleted helper", None);
let mut translator = Translator::new(dir.path().to_path_buf(), Arc::clone(&subagents));
translator.mark_settled();
translate_lines(
&mut translator,
&[r#"{"type":"system","subtype":"background_tasks_changed","tasks":[]}"#],
);
subagents
.delete(&["toolu_deleted".to_string()], true)
.expect("delete corrected subagent");
assert!(
translate_lines(
&mut translator,
&[r#"{"type":"system","subtype":"task_notification","tool_use_id":"toolu_deleted","status":"completed","summary":"late"}"#],
)
.is_empty(),
"a missing subagent is not a background command"
);
}
/// 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
/// `in_turn` guard this reported the session idle in the middle of one,
+4
View File
@@ -1908,6 +1908,7 @@ impl SessionManager {
session.transcript_path(),
&session.sink,
session.subagents(),
status,
)?);
}
// Nothing is live for this one -- a session whose launch failed
@@ -2463,6 +2464,7 @@ fn launch(
&transcript_path,
&sink,
&subagents,
status,
)
})
.transpose()?,
@@ -2514,6 +2516,7 @@ fn make_driver(
transcript_path: &Path,
sink: &EventSink,
subagents: &Arc<Subagents>,
initial_status: SessionStatus,
) -> Result<Arc<dyn Driver>> {
Ok(match provider.kind {
DriverKind::Echo => Arc::new(EchoDriver::new(
@@ -2541,6 +2544,7 @@ fn make_driver(
dir,
sink.clone(),
Arc::clone(subagents),
initial_status,
)?),
DriverKind::CodexCli => Arc::new(CodexDriver::launch(
meta,