Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c118020b29 | ||
|
|
7cb14c7aae | ||
|
|
80a9399905 | ||
|
|
9d9a2e723d | ||
|
|
bbafff5a98 | ||
|
|
17e1dc4049 | ||
|
|
436cb194c3 | ||
|
|
f689c916bf | ||
|
|
88233cb96d | ||
|
|
efbccb4e11 |
@@ -62,6 +62,11 @@ pub struct SessionRecord {
|
|||||||
/// resumes and [`Session::activity_detail`] is cleared with it. This
|
/// resumes and [`Session::activity_detail`] is cleared with it. This
|
||||||
/// field is daemon-local bookkeeping only — it never rides the wire.
|
/// field is daemon-local bookkeeping only — it never rides the wire.
|
||||||
pub reported_activity: Option<crate::activity::ReportedActivity>,
|
pub reported_activity: Option<crate::activity::ReportedActivity>,
|
||||||
|
/// Flipped once the natural-exit settle step (#82) has been applied or
|
||||||
|
/// refused for this session. `Dead` is observable before settling ends
|
||||||
|
/// (#115), so this is the observable that says its outcomes are safe to
|
||||||
|
/// assert on. Daemon-local bookkeeping; never rides the wire.
|
||||||
|
pub settled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
@@ -294,6 +299,17 @@ impl SessionManager {
|
|||||||
sessions.get(&id).map(|r| r.session.status)
|
sessions.get(&id).map(|r| r.session.status)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the natural-exit settle step (#82) has finished for this
|
||||||
|
/// session: `Some(true)` once applied or refused, `Some(false)` while
|
||||||
|
/// still pending, `None` if the session is unknown. `Dead` is observable
|
||||||
|
/// before settling ends (#115), so anything asserting on a dead
|
||||||
|
/// session's worktree waits on this rather than inferring settle
|
||||||
|
/// completion from its side effects.
|
||||||
|
pub async fn is_settled(&self, id: Uuid) -> Option<bool> {
|
||||||
|
let sessions = self.inner.sessions.lock().await;
|
||||||
|
sessions.get(&id).map(|r| r.settled)
|
||||||
|
}
|
||||||
|
|
||||||
/// Spawn a new session. On success returns the freshly-created `Session`.
|
/// Spawn a new session. On success returns the freshly-created `Session`.
|
||||||
pub async fn create(&self, req: CreateSessionRequest) -> Result<Session, SessionError> {
|
pub async fn create(&self, req: CreateSessionRequest) -> Result<Session, SessionError> {
|
||||||
let (effective_cwd, worktree_path, worktree_base_repo) = match &req.worktree {
|
let (effective_cwd, worktree_path, worktree_base_repo) = match &req.worktree {
|
||||||
@@ -429,6 +445,7 @@ impl SessionManager {
|
|||||||
worktree_branch: req.worktree.as_ref().map(|w| w.branch.clone()),
|
worktree_branch: req.worktree.as_ref().map(|w| w.branch.clone()),
|
||||||
bridge,
|
bridge,
|
||||||
reported_activity: None,
|
reported_activity: None,
|
||||||
|
settled: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -657,6 +674,7 @@ impl SessionManager {
|
|||||||
worktree_branch: None,
|
worktree_branch: None,
|
||||||
bridge,
|
bridge,
|
||||||
reported_activity: None,
|
reported_activity: None,
|
||||||
|
settled: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let _ = self
|
let _ = self
|
||||||
@@ -1069,6 +1087,14 @@ async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Settle has now been applied or refused either way (#115): from here on
|
||||||
|
// its outcomes are safe to observe.
|
||||||
|
{
|
||||||
|
let mut sessions = inner.sessions.lock().await;
|
||||||
|
if let Some(record) = sessions.get_mut(&id) {
|
||||||
|
record.settled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let _ = inner.sidecar.write(&session);
|
let _ = inner.sidecar.write(&session);
|
||||||
let _ = inner.events.send(CatalogEvent::SessionUpdated { session });
|
let _ = inner.events.send(CatalogEvent::SessionUpdated { session });
|
||||||
|
|||||||
@@ -189,8 +189,23 @@ fn a_foreground_pipeline_whose_leader_exited_is_still_awaiting_input() {
|
|||||||
"never observed a foreground group whose leader had exited"
|
"never observed a foreground group whose leader had exited"
|
||||||
);
|
);
|
||||||
|
|
||||||
assert!(
|
// The leader can be gone while the surviving stage is still setting up:
|
||||||
awaiting_input(pane.pane_pid, &pane.tty, &ProcGroups::snapshot()),
|
// `true` exits as soon as both stages are forked, before the reader has
|
||||||
"a surviving pipeline member still holds the tty"
|
// opened the tty and parked. Poll for the answer, but only accept it
|
||||||
);
|
// while the state this test exists for still holds — otherwise a shell
|
||||||
|
// that regained the terminal would satisfy it vacuously.
|
||||||
|
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||||
|
let mut holds = false;
|
||||||
|
while std::time::Instant::now() < deadline {
|
||||||
|
let groups = ProcGroups::snapshot();
|
||||||
|
let still_leaderless = pgrp_and_tpgid(pane.pane_pid).is_some_and(|(pgrp, tpgid)| {
|
||||||
|
tpgid > 0 && tpgid != pgrp && pgrp_and_tpgid(tpgid).is_none()
|
||||||
|
});
|
||||||
|
if still_leaderless && awaiting_input(pane.pane_pid, &pane.tty, &groups) {
|
||||||
|
holds = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(20));
|
||||||
|
}
|
||||||
|
assert!(holds, "a surviving pipeline member still holds the tty");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -397,6 +397,12 @@ async fn watcher_refreshes_the_blocked_work_indicator() {
|
|||||||
init_bare_repo(origin.path());
|
init_bare_repo(origin.path());
|
||||||
push_all_to_origin(repo.path(), origin.path());
|
push_all_to_origin(repo.path(), origin.path());
|
||||||
|
|
||||||
|
// Subscribe before the watcher starts so every pass's emission is
|
||||||
|
// captured — otherwise the breakdown event can land between the
|
||||||
|
// snapshot observation below and the drain, and the quiet window
|
||||||
|
// would see it as an illegitimate re-emit.
|
||||||
|
let mut events = fx.manager.events();
|
||||||
|
|
||||||
fx.manager.start_liveness_watcher_with_blocked_work(
|
fx.manager.start_liveness_watcher_with_blocked_work(
|
||||||
Duration::from_millis(50),
|
Duration::from_millis(50),
|
||||||
Duration::from_millis(100),
|
Duration::from_millis(100),
|
||||||
@@ -418,14 +424,38 @@ async fn watcher_refreshes_the_blocked_work_indicator() {
|
|||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
// A steady state must not re-emit every tick (#83 change-detection).
|
// Consume through the emission that carried the breakdown (#83
|
||||||
let mut events = fx.manager.events();
|
// change-detection): the quiet window below must start after the last
|
||||||
|
// legitimate change, not race it into the channel.
|
||||||
|
loop {
|
||||||
|
match tokio::time::timeout(Duration::from_secs(5), events.recv()).await {
|
||||||
|
Ok(Ok(CatalogEvent::SessionUpdated { session }))
|
||||||
|
if matches!(
|
||||||
|
&session.blocked_work,
|
||||||
|
Some(b) if b.uncommitted_files >= 1 && b.untracked_files >= 1
|
||||||
|
) =>
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Ok(Ok(_)) => continue,
|
||||||
|
_ => panic!("never saw the SessionUpdated carrying the breakdown"),
|
||||||
|
}
|
||||||
|
}
|
||||||
while events.try_recv().is_ok() {}
|
while events.try_recv().is_ok() {}
|
||||||
tokio::time::sleep(Duration::from_millis(400)).await;
|
tokio::time::sleep(Duration::from_millis(400)).await;
|
||||||
assert!(
|
// Only a *changed* indicator counts as a re-emit (#83 change-detection).
|
||||||
!matches!(events.try_recv(), Ok(CatalogEvent::SessionUpdated { .. })),
|
// The activity pass may still legitimately announce e.g. Active→Idle
|
||||||
"an unchanged worktree must not re-emit SessionUpdated"
|
// (its debounce is time-based, not worktree-based), and its payload
|
||||||
);
|
// carries today's blocked_work unchanged.
|
||||||
|
while let Ok(CatalogEvent::SessionUpdated { session }) = events.try_recv() {
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
&session.blocked_work,
|
||||||
|
Some(b) if b.uncommitted_files >= 1 && b.untracked_files >= 1
|
||||||
|
),
|
||||||
|
"an unchanged worktree must not re-emit a changed indicator"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cleanup on kill is unconditional once the guard passes (#79): even a spec
|
/// Cleanup on kill is unconditional once the guard passes (#79): even a spec
|
||||||
@@ -848,6 +878,17 @@ async fn natural_exit_of_a_pristine_worktree_removes_it() {
|
|||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
// (`Dead` is observable a moment before the settle step removes the tree.)
|
||||||
|
wait_for_async(Duration::from_secs(3), || async {
|
||||||
|
!wt_path.exists()
|
||||||
|
&& fx
|
||||||
|
.retained_store()
|
||||||
|
.load_all()
|
||||||
|
.map(|all| all.is_empty())
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
!wt_path.exists(),
|
!wt_path.exists(),
|
||||||
"a pristine worktree leaves nothing behind"
|
"a pristine worktree leaves nothing behind"
|
||||||
@@ -905,6 +946,14 @@ async fn natural_exit_without_a_retained_store_destroys_nothing() {
|
|||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
// The settle step has been applied or refused either way — here refused,
|
||||||
|
// since there is no store to record the tree into. Only from this point
|
||||||
|
// is "the tree survived" a settled fact rather than a race (#115).
|
||||||
|
wait_for_async(Duration::from_secs(3), || async {
|
||||||
|
manager.is_settled(s.id).await == Some(true)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
wt_path.exists(),
|
wt_path.exists(),
|
||||||
"nothing may be destroyed when it cannot be recorded"
|
"nothing may be destroyed when it cannot be recorded"
|
||||||
|
|||||||
+26
-25
@@ -5,8 +5,7 @@ sidebar (#107) and the TUI sidebar (#108) describe a session the same way.
|
|||||||
Read this before changing either. Both clients implement exactly what is
|
Read this before changing either. Both clients implement exactly what is
|
||||||
written here; if it needs to change, change it here first.
|
written here; if it needs to change, change it here first.
|
||||||
|
|
||||||
A card has **line 1** always and **line 2** only when line 2 has something to
|
A card is always two lines: **line 1** and **line 2**.
|
||||||
say.
|
|
||||||
|
|
||||||
## Line 1 — name + actions
|
## Line 1 — name + actions
|
||||||
|
|
||||||
@@ -25,32 +24,32 @@ badge move off this row into line 2.
|
|||||||
|
|
||||||
## Line 2 — meta line (adaptive)
|
## Line 2 — meta line (adaptive)
|
||||||
|
|
||||||
Rendered only when at least one of these holds:
|
Line 2 always renders — never an empty placeholder row either, since its
|
||||||
|
unconditional fragments (see below) always have something to show. Its
|
||||||
|
*fragments* are adaptive: some show only when their trigger holds. The line
|
||||||
|
itself is not.
|
||||||
|
|
||||||
1. The session has been held in its current activity state longer than
|
Line 2 reads left to right:
|
||||||
**30 seconds** (`activity_since` from #102). A freshly-active session
|
|
||||||
stays one line.
|
|
||||||
2. Its worktree holds blocked work: `blocked_work` is `Some` and not clear
|
|
||||||
(`InTheWay::is_clear()`), rendered in `formatInTheWay`'s phrasing on web
|
|
||||||
and as `⚠ <summary>` on the TUI.
|
|
||||||
3. It has exited: `status == Dead`.
|
|
||||||
4. An agent-reported state (#105) of `Error` is in force (see Precedence).
|
|
||||||
|
|
||||||
Otherwise line 2 is absent — never an empty placeholder row.
|
|
||||||
|
|
||||||
When present, line 2 reads left to right:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
<dot> <duration> [<detail>] [<blocked work> | exited] [<command>]
|
<dot> [<duration>] [<detail>] [<blocked work> | exited] <command>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Its minimum content is therefore the dot and the command.
|
||||||
|
|
||||||
`<dot>` is a single filled glyph (`●` on both clients) carrying the activity
|
`<dot>` is a single filled glyph (`●` on both clients) carrying the activity
|
||||||
state's colour, and `<duration>` follows in the same colour. There is no state
|
state's colour, and `<duration>` follows in the same colour when shown. There
|
||||||
word and no second badge: the dot exists so the state is still legible when a
|
is no state word and no second badge: the dot exists so the state is still
|
||||||
row is dimmed or the terminal renders the duration in the default fg. The dot
|
legible when a row is dimmed or the terminal renders the duration in the
|
||||||
and duration are unconditional parts of line 2; the bracketed fragments appear
|
default fg. The dot is an unconditional part of line 2; the bracketed
|
||||||
only when their trigger holds. A payload that predates #102 (no usable
|
fragments appear only when their trigger holds.
|
||||||
`activity_since`) contributes no duration — the dot still renders.
|
|
||||||
|
`<duration>` renders only once the session has been held in its current
|
||||||
|
activity state longer than **30 seconds** (`activity_since` from #102) — under
|
||||||
|
that threshold the duration is absent and the dot renders alone. A payload
|
||||||
|
that predates #102 (no usable `activity_since`) likewise contributes no
|
||||||
|
duration — the dot still renders. This rule now governs only the duration
|
||||||
|
fragment; it no longer decides whether line 2 exists.
|
||||||
|
|
||||||
`<detail>` is an agent-reported string (#105) — e.g. why a session is in
|
`<detail>` is an agent-reported string (#105) — e.g. why a session is in
|
||||||
`Error`. It sits right after the duration, in the muted style, and shows only
|
`Error`. It sits right after the duration, in the muted style, and shows only
|
||||||
@@ -60,9 +59,11 @@ truncates with `…` ahead of the trailing fragments — a long detail never
|
|||||||
pushes `<blocked work>`, `exited`, or `<command>` off the line, and neither
|
pushes `<blocked work>`, `exited`, or `<command>` off the line, and neither
|
||||||
client ever lets it wrap.
|
client ever lets it wrap.
|
||||||
|
|
||||||
`<command>` is optional and muted: the session's command line, shown only when
|
`<command>` is unconditional and muted: the session's command line, shown on
|
||||||
the rendered name came from `metadata.name` and differs from the command (the
|
every card in both clients — no omission in the TUI, no name-vs-command
|
||||||
web has always surfaced this; the TUI may omit it).
|
comparison gating it. A session named by its command line then shows the same
|
||||||
|
string on both lines; that is accepted, the price of a row that never changes
|
||||||
|
height.
|
||||||
|
|
||||||
## Colour per activity state
|
## Colour per activity state
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user