Reconcile Claude background task state
This commit is contained in:
1 parent
f0661919bb
commit
9fd21af4e8
6 files changed
+310
-24
No files matched your search
@@ -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
|
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
|
echo session is the whole rig; the helpers stagger a second apart so each
|
||||||
reply is its own.
|
reply is its own.
|
||||||
- **Whether work is outstanding has two sources and needs both.** The
|
- **Claude's background-task level is authority; two edge sources are the
|
||||||
translator's `open_tasks` is what it watched start — the only thing that
|
fallback.** Since Claude Code 2.1.261,
|
||||||
knows about a backgrounded command — and `Subagents::any_open` reads the
|
`background_tasks_changed { tasks: [...] }` replaces the live set and repairs
|
||||||
directory, which is the only thing that knows about a subagent started
|
a missed ending edge. An adopted CLI is sent a repeated `initialize` to ask
|
||||||
before this translator existed. That second one is every subagent a session
|
for the current set. Reconcile only between turns or at a result boundary:
|
||||||
has when the backend is updated under it: adoption reads stdout from a
|
a foreground agent is legitimately absent from a background-only snapshot.
|
||||||
recorded offset, so those `task_started` lines are already behind it.
|
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
|
- **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
|
||||||
|
|||||||
@@ -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
|
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 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
|
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
|
in `Status::Waiting`'s bookkeeping. Handled once, however many of the two
|
||||||
lifecycle shapes (`task_notification`, `task_updated`) arrive — whichever gets
|
lifecycle shapes (`task_notification`, `task_updated`) arrive — whichever gets
|
||||||
|
|||||||
+20
-6
@@ -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
|
status ends it from the update, since the failure to avoid is a subagent
|
||||||
nothing ever finishes.
|
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
|
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
|
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
|
("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
|
**While any task is outstanding the session's turn ends in
|
||||||
`Status Waiting` rather than `Idle`.** `Idle` means "waiting for a person",
|
`Status Waiting` rather than `Idle`.** `Idle` means "waiting for a person",
|
||||||
and a session with a backgrounded subagent is not doing that. Two sources:
|
and a session with a backgrounded subagent is not doing that. The edge
|
||||||
the translator's `open_tasks`, and `Subagents::any_open` -- which is what
|
fallback has two sources: the translator's `open_tasks`, and
|
||||||
covers a subagent launched before a backend restart adopted the session,
|
`Subagents::any_open` -- which covers a subagent launched before a backend
|
||||||
whose `task_started` is behind the offset its stdout is read from. That
|
restart adopted the session, whose `task_started` is behind the durable
|
||||||
second lookup is also what lets such a subagent's ending be recognised at
|
stdout offset. On current Claude versions the replace-semantics level above
|
||||||
all.
|
reconciles both at a safe turn boundary.
|
||||||
|
|
||||||
**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
|
||||||
|
|||||||
@@ -214,6 +214,7 @@ impl ClaudeDriver {
|
|||||||
session_dir: &Path,
|
session_dir: &Path,
|
||||||
sink: EventSink,
|
sink: EventSink,
|
||||||
subagents: Arc<Subagents>,
|
subagents: Arc<Subagents>,
|
||||||
|
initial_status: SessionStatus,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
let state = Arc::new(Mutex::new(Translator::new(
|
let state = Arc::new(Mutex::new(Translator::new(
|
||||||
session_dir.to_path_buf(),
|
session_dir.to_path_buf(),
|
||||||
@@ -261,6 +262,9 @@ impl ClaudeDriver {
|
|||||||
Self::start(meta, provider, transport, session_dir)?
|
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,
|
// A process this driver has just started has been asked for nothing,
|
||||||
// which is what idle means. Said here because nothing else will: the
|
// which is what idle means. Said here because nothing else will: the
|
||||||
@@ -320,14 +324,22 @@ impl ClaudeDriver {
|
|||||||
format!("{} {}", provider.name, transport.describe()),
|
format!("{} {}", provider.name, transport.describe()),
|
||||||
));
|
));
|
||||||
|
|
||||||
Ok(Self {
|
let driver = Self {
|
||||||
sink,
|
sink,
|
||||||
queue,
|
queue,
|
||||||
to_child,
|
to_child,
|
||||||
state,
|
state,
|
||||||
session_dir: session_dir.to_path_buf(),
|
session_dir: session_dir.to_path_buf(),
|
||||||
reading,
|
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
|
/// Starts a new CLI for this session, with its streams in the session
|
||||||
@@ -866,8 +878,12 @@ fn translate_line(
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
let opens_a_model_call = starts_a_model_call(&message);
|
let opens_a_model_call = starts_a_model_call(&message);
|
||||||
|
let parent_running = queue.lock().unwrap().running;
|
||||||
let (events, new_session_id, before) = {
|
let (events, new_session_id, before) = {
|
||||||
let mut state = state.lock().unwrap();
|
let mut state = state.lock().unwrap();
|
||||||
|
if parent_running {
|
||||||
|
state.mark_running();
|
||||||
|
}
|
||||||
let before = state.session_id.clone();
|
let before = state.session_id.clone();
|
||||||
let events = state.translate(&message);
|
let events = state.translate(&message);
|
||||||
let after = state.session_id.clone();
|
let after = state.session_id.clone();
|
||||||
|
|||||||
@@ -63,6 +63,14 @@ struct PendingRequest {
|
|||||||
answers: HashMap<String, String>,
|
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.
|
/// Translation state: stream-json lines in, common events out.
|
||||||
pub(super) struct Translator {
|
pub(super) struct Translator {
|
||||||
pub(super) session_id: Option<String>,
|
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
|
/// translator existed, which is every one of them after a backend
|
||||||
/// restart adopts a running session.
|
/// restart adopts a running session.
|
||||||
open_tasks: HashSet<String>,
|
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.
|
/// 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
|
/// Each leaves here with its `tool_result`; a failure keeps its text because that is the part a
|
||||||
/// reader needs to act on.
|
/// 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,
|
/// narrow: whether a task reporting back means the session has gone idle,
|
||||||
/// or is merely one of several things happening inside a turn.
|
/// or is merely one of several things happening inside a turn.
|
||||||
in_turn: bool,
|
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 {
|
impl Translator {
|
||||||
@@ -155,11 +177,28 @@ impl Translator {
|
|||||||
rate_limited: false,
|
rate_limited: false,
|
||||||
tasks: HashMap::new(),
|
tasks: HashMap::new(),
|
||||||
open_tasks: HashSet::new(),
|
open_tasks: HashSet::new(),
|
||||||
|
background_tasks: None,
|
||||||
|
awaiting_task_summaries: HashMap::new(),
|
||||||
patches: HashSet::new(),
|
patches: HashSet::new(),
|
||||||
in_turn: false,
|
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.
|
/// 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
|
/// 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.
|
/// 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);
|
let events = self.translate_line(message);
|
||||||
if events.iter().any(closes_a_turn) {
|
if events.iter().any(closes_a_turn) {
|
||||||
self.in_turn = false;
|
self.in_turn = false;
|
||||||
|
self.settled = true;
|
||||||
} else if events.iter().any(super::proves_a_turn) {
|
} else if events.iter().any(super::proves_a_turn) {
|
||||||
self.in_turn = true;
|
self.in_turn = true;
|
||||||
|
self.settled = false;
|
||||||
}
|
}
|
||||||
events
|
events
|
||||||
}
|
}
|
||||||
@@ -388,6 +429,10 @@ impl Translator {
|
|||||||
if tokens > 0 {
|
if tokens > 0 {
|
||||||
events.push(Event::UsageDelta { tokens, context });
|
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
|
// Idle means "waiting for a person", and a session with a
|
||||||
// backgrounded subagent or command still running is not doing
|
// backgrounded subagent or command still running is not doing
|
||||||
// that -- it is waiting for itself, and will speak again with
|
// 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") => {
|
Some("task_started" | "task_progress" | "task_updated" | "task_notification") => {
|
||||||
self.translate_task(message)
|
self.translate_task(message)
|
||||||
}
|
}
|
||||||
|
Some("background_tasks_changed") => self.translate_background_tasks(message),
|
||||||
Some("compact_boundary") => {
|
Some("compact_boundary") => {
|
||||||
let meta = &message["compact_metadata"];
|
let meta = &message["compact_metadata"];
|
||||||
vec![Event::Compacted {
|
vec![Event::Compacted {
|
||||||
@@ -582,6 +628,7 @@ impl Translator {
|
|||||||
// parent's own message, which arrives first and carries the
|
// parent's own message, which arrives first and carries the
|
||||||
// title this side shows.
|
// title this side shows.
|
||||||
if let Some(about) = about {
|
if let Some(about) = about {
|
||||||
|
self.awaiting_task_summaries.remove(&about);
|
||||||
self.open_tasks.insert(about);
|
self.open_tasks.insert(about);
|
||||||
}
|
}
|
||||||
Vec::new()
|
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
|
/// Whether the session has work of its own still running: the difference
|
||||||
/// between `Idle` and [`SessionStatus::Waiting`].
|
/// between `Idle` and [`SessionStatus::Waiting`].
|
||||||
///
|
///
|
||||||
/// Two sources because neither covers the other. `open_tasks` holds what
|
/// The level is authoritative when a current CLI has supplied it. The
|
||||||
/// this translator watched start, which is the only thing that knows
|
/// edge fallback has two sources because neither covers the other:
|
||||||
/// about a backgrounded *command* -- it has no subagent. The registry
|
/// `open_tasks` knows about a backgrounded command, which has no
|
||||||
/// holds what is on disk, which is the only thing that knows about a
|
/// subagent, while the registry knows about a subagent started before
|
||||||
/// subagent that started before this translator did.
|
/// this translator existed.
|
||||||
///
|
///
|
||||||
/// `session_running` is true by construction: this is only ever asked
|
/// `session_running` is true by construction: this is only ever asked
|
||||||
/// while translating a line the session's process just wrote.
|
/// while translating a line the session's process just wrote.
|
||||||
fn work_outstanding(&self) -> bool {
|
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.
|
/// 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
|
// `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
|
// from. `finish` below closes it either way, so a second line for the
|
||||||
// same task still finds nothing.
|
// 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();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
@@ -661,7 +778,7 @@ impl Translator {
|
|||||||
self.subagents
|
self.subagents
|
||||||
.record(&about, Event::AssistantText { delta: summary });
|
.record(&about, Event::AssistantText { delta: summary });
|
||||||
}
|
}
|
||||||
} else {
|
} else if reconciled != Some(TaskReport::Subagent) {
|
||||||
events.push(Event::ToolUpdate {
|
events.push(Event::ToolUpdate {
|
||||||
id: about.clone(),
|
id: about.clone(),
|
||||||
// A task that stopped without a word still has to say so: the
|
// 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.
|
// 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.work_outstanding() && !self.in_turn {
|
if was_open && !self.work_outstanding() && !self.in_turn {
|
||||||
events.push(Event::Status {
|
events.push(Event::Status {
|
||||||
state: SessionStatus::Idle,
|
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:
|
/// 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,
|
||||||
|
|||||||
@@ -1908,6 +1908,7 @@ impl SessionManager {
|
|||||||
session.transcript_path(),
|
session.transcript_path(),
|
||||||
&session.sink,
|
&session.sink,
|
||||||
session.subagents(),
|
session.subagents(),
|
||||||
|
status,
|
||||||
)?);
|
)?);
|
||||||
}
|
}
|
||||||
// Nothing is live for this one -- a session whose launch failed
|
// Nothing is live for this one -- a session whose launch failed
|
||||||
@@ -2463,6 +2464,7 @@ fn launch(
|
|||||||
&transcript_path,
|
&transcript_path,
|
||||||
&sink,
|
&sink,
|
||||||
&subagents,
|
&subagents,
|
||||||
|
status,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.transpose()?,
|
.transpose()?,
|
||||||
@@ -2514,6 +2516,7 @@ fn make_driver(
|
|||||||
transcript_path: &Path,
|
transcript_path: &Path,
|
||||||
sink: &EventSink,
|
sink: &EventSink,
|
||||||
subagents: &Arc<Subagents>,
|
subagents: &Arc<Subagents>,
|
||||||
|
initial_status: SessionStatus,
|
||||||
) -> Result<Arc<dyn Driver>> {
|
) -> Result<Arc<dyn Driver>> {
|
||||||
Ok(match provider.kind {
|
Ok(match provider.kind {
|
||||||
DriverKind::Echo => Arc::new(EchoDriver::new(
|
DriverKind::Echo => Arc::new(EchoDriver::new(
|
||||||
@@ -2541,6 +2544,7 @@ fn make_driver(
|
|||||||
dir,
|
dir,
|
||||||
sink.clone(),
|
sink.clone(),
|
||||||
Arc::clone(subagents),
|
Arc::clone(subagents),
|
||||||
|
initial_status,
|
||||||
)?),
|
)?),
|
||||||
DriverKind::CodexCli => Arc::new(CodexDriver::launch(
|
DriverKind::CodexCli => Arc::new(CodexDriver::launch(
|
||||||
meta,
|
meta,
|
||||||
|
|||||||
Reference in new issue
Block a user