Let --resume take a thread that reported, and refuse only a cancelled one

2d4480ea0948 · AtlantisPleb · · parent a8d1aa5e892b

Let --resume take a thread that reported, and refuse only a cancelled one

`assert_resumable` refused every status but `open`, which was right while the
only way to end a thread was `DELETE`: every exit left a cancelled thread and
resuming one could only ever show history. Now that a session reports how it
ended, `POST /threads/{id}/grants` reopens a `succeeded` or `failed` thread —
writing what it reported into the transcript as `thread.reopened` before it
mints — so the thread this CLI closed normally is exactly the one a reader
comes back to, and the client gate was refusing it before the request went out.

A cancelled thread stays refused, here and at the server. `DELETE` is a
disposal and a caller that used it asked for the thread to be over.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoZMfWRSGnf6FZX2Ar9rQ2
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified crates/openagents-cli/src/resume.rs

Diff

1 file changed, +41 -14

crates/openagents-cli/src/resume.rs modified +41 -14

@@ -127,9 +127,11 @@ pub fn repository_of(objective: &str) -> Option<String> {

127 127
///
128 128
/// Filtered to the named repository unless `all`, because a reader resuming
129 129
/// work is almost always resuming it where they are standing. Terminal threads
130
/// stay in the list: this CLI revokes its thread on a clean exit, so an
131
/// open-only list would usually be empty, and picking a terminal one gets the
132
/// refusal that teaches why rather than a listing that hides it.
130
/// stay in the list: this CLI ends its thread on a clean exit, so an open-only
131
/// list would usually be empty — and a thread that reported is resumable, which
132
/// is most of what this picker is for. A cancelled one is listed too, and
133
/// picking it gets the refusal that teaches why rather than a listing that
134
/// hides it.
133 135
pub fn resumable_threads(
134 136
    threads: &[ThreadSummary],
135 137
    repository: Option<&str>,

@@ -150,18 +152,28 @@ pub fn resumable_threads(

150 152
151 153
/// Refuse a thread that cannot be continued.
152 154
///
153
/// A terminal thread holds no authority and its transcript is closed — the
154
/// server refuses both a re-mint and a new event — so resuming one could only
155
/// ever show history. The refusal names the status, because `cancelled` after
156
/// a clean exit and `failed` after an error call for different next steps.
155
/// A thread that reported can be: `POST /threads/{id}/grants` reopens a
156
/// `succeeded` or `failed` thread, writing what it reported into the
157
/// transcript as `thread.reopened` before minting fresh authority. That is the
158
/// point of a session saying how it ended rather than cancelling itself — a
159
/// thread this CLI closed normally is exactly the one a reader comes back to.
160
///
161
/// A cancelled thread is refused, here and by the server. `DELETE` is a
162
/// disposal and a caller that used it asked for the thread to be over, so
163
/// resuming it would make cancellation mean nothing.
164
///
165
/// This used to refuse every status but `open`, which was right while the CLI
166
/// had no way to end a thread except `DELETE`: every honest exit left a
167
/// cancelled thread, and resuming one could only ever show history.
157 168
pub fn assert_resumable(thread: &ThreadSummary) -> Result<(), String> {
158
    if thread.status == "open" {
169
    if thread.status != "cancelled" {
159 170
        return Ok(());
160 171
    }
161 172
    Err(format!(
162
        "thread {} is {}: its transcript is closed and it holds no authority to re-grant. \
173
        "thread {} was cancelled, so it holds no authority to re-grant and cannot be resumed. \
174
         A thread that reported how it ended can be; a cancelled one is over. \
163 175
         Start a new session with `oa coder` instead.",
164
        thread.id, thread.status
176
        thread.id
165 177
    ))
166 178
}
167 179

@@ -597,18 +609,33 @@ mod tests {

597 609
        assert!(filtered.is_empty());
598 610
    }
599 611
612
    /// A cancelled thread is refused and a reported one is not.
613
    ///
614
    /// The whole point of a session reporting how it ended rather than
615
    /// cancelling itself: a thread this CLI closed normally is resumable, and
616
    /// one somebody threw away is not. Refusing every terminal status meant a
617
    /// client could keep a thread resumable only by never saying what it did.
600 618
    #[test]
601
    fn a_terminal_thread_is_refused_by_status() {
602
        let thread = ThreadSummary {
619
    fn a_cancelled_thread_is_refused_and_a_reported_one_is_not() {
620
        let with_status = |status: &str| ThreadSummary {
603 621
            id: "t4".into(),
604
            status: "cancelled".into(),
622
            status: status.into(),
605 623
            objective: String::new(),
606 624
            event_count: 0,
607 625
            started_at: None,
608 626
            repository: None,
609 627
        };
610
        let error = assert_resumable(&thread).unwrap_err();
628
629
        let error = assert_resumable(&with_status("cancelled")).unwrap_err();
611 630
        assert!(error.contains("cancelled"), "{error}");
631
632
        for reported in ["open", "succeeded", "failed"] {
633
            assert!(
634
                assert_resumable(&with_status(reported)).is_ok(),
635
                "a {reported} thread was refused, so a session that said how it ended \
636
                 cannot be resumed"
637
            );
638
        }
612 639
    }
613 640
614 641
    /// The replay is the wire transcript, not the interface's: a recorded

This page updates live while a promote is in flight · changelog