A run cannot be abandoned #9

Closed
opened 2026-09-13 16:14:16 -04:00 by cmoriarty · 1 comment
Owner

Reported from the running system. RunStatus.ABANDONED, the osf.run.abandoned event and its
reducer all exist, but nothing can emit the event: there is no route, no scheduler method and no
control in the UI. A run the operator has given up on stays running forever — holding a workspace
slot and an opencode server, counting against the run cap, and showing as live work everywhere.
Runs in this project's history have ended that way and the only cure was kill. Most recently
soundcheck #30 (run #5) had to be stopped by shutting osfd down, and restarting osfd would have
resumed it.

What it should do

  • POST /api/runs/{run_id}/abandon with {reason, id}:
    • stops every step still running, and releases its lease, slot and session;
    • stops the run's opencode server;
    • appends exactly one osf.run.abandoned carrying the operator's reason;
    • is idempotent by request id, like every other mutating route;
    • refuses with 409 for a run already in a terminal status — rewriting a settled ending is
      what an append-only log exists to prevent.
  • Steps that never ran end as cancelled/skipped so the run's graph reads as ended, not pending.
  • A control on the run page, with a confirmation that says what will stop, and the reason field.
  • Once abandoned, the run can be archived (ledger archive already accepts abandoned).

Acceptance

tests/unit/test_abandon.py (4 tests, currently failing) is the specification; do not weaken it.
Plus: a running agent step's session is actually stopped, the run leaves the scheduler's active
set, and the UI control works end to end.


Dogfood #2 and #3's brief, filed as an issue. Source: docs/issues/05-a-run-cannot-be-abandoned.md.

**Reported from the running system.** `RunStatus.ABANDONED`, the `osf.run.abandoned` event and its reducer all exist, but nothing can emit the event: there is no route, no scheduler method and no control in the UI. A run the operator has given up on stays `running` forever — holding a workspace slot and an opencode server, counting against the run cap, and showing as live work everywhere. Runs in this project's history have ended that way and the only cure was `kill`. Most recently soundcheck #30 (run #5) had to be stopped by shutting osfd down, and restarting osfd would have resumed it. ## What it should do - `POST /api/runs/{run_id}/abandon` with `{reason, id}`: - stops every step still running, and releases its lease, slot and session; - stops the run's opencode server; - appends exactly one `osf.run.abandoned` carrying the operator's reason; - is idempotent by request `id`, like every other mutating route; - refuses with **409** for a run already in a terminal status — rewriting a settled ending is what an append-only log exists to prevent. - Steps that never ran end as cancelled/skipped so the run's graph reads as ended, not pending. - A control on the run page, with a confirmation that says what will stop, and the reason field. - Once abandoned, the run can be archived (ledger archive already accepts `abandoned`). ## Acceptance `tests/unit/test_abandon.py` (4 tests, currently failing) is the specification; do not weaken it. Plus: a running agent step's session is actually stopped, the run leaves the scheduler's active set, and the UI control works end to end. --- *Dogfood #2 and #3's brief, filed as an issue. Source: `docs/issues/05-a-run-cannot-be-abandoned.md`.*
Author
Owner

Verified end to end and closing. The behaviour shipped in 465c9ad (feat: abandon a run), and this pass added the missing browser test (8e0a6e7). Nothing else needed to change, so there was no OpenSpec change for it.

Checked against the acceptance list

  • POST /api/runs/{id}/abandon {reason, id}
    • appends exactly one osf.run.abandoned with the reason and the steps it is stopping;
    • is idempotent by id;
    • returns 409 for a run that already ended.
    • tests/unit/test_abandon.py: 4/4 pass, unweakened.
  • The engine stops the running step, confirming the abort like any stop, gives runners ABANDON_SETTLE_S, then cancels every step that isn't terminal and expires its gates (test_liveness.py::test_abandoning_a_run_stops_its_running_step_and_ends_the_rest). The supervisor reaps the server of a run that is no longer live (test_runtime.py::test_reaping_stops_the_server_of_a_run_that_is_over_and_frees_the_slot).
  • UI control:
    • ✕ Abandon run in the stage header, shown only for a run that hasn't ended;
    • a confirmation that says in words what will stop;
    • an optional reason that goes on the log;
    • Keep running focused, and the destructive button last.
  • New e2e/abandon.spec.ts: Escape sends nothing; confirm sends the reason once with an id; a 409 keeps the dialog open with the server's message; an ended run has no Abandon.

Real osfd, end to end (scratch state dir, a run whose explore step was running with a live opencode serve on :8900)

  • I clicked Abandon run on the page. The dialog read: Stops the step that is working now: Explore the codebase. Cancels 22 steps that have not started. I entered a reason and confirmed.
  • Results:
    • the run is abandoned;
    • explore was stopped and the 22 unstarted steps are cancelled;
    • the supervisor logged opencode stopped, slot released, and port 8900 was free;
    • the event carries reason: "verifying Forgejo #9 from the page" and stopping: ["explore"].
  • Abandoning again returned 409 already_ended, and the abandoned run archived normally.

The full Playwright suite passes: 37 passed, 3 skipped for live osfd.

Verified end to end and closing. The behaviour shipped in 465c9ad (`feat: abandon a run`), and this pass added the missing browser test (8e0a6e7). Nothing else needed to change, so there was no OpenSpec change for it. **Checked against the acceptance list** - `POST /api/runs/{id}/abandon {reason, id}` - appends exactly one `osf.run.abandoned` with the reason and the steps it is stopping; - is idempotent by `id`; - returns **409** for a run that already ended. - `tests/unit/test_abandon.py`: 4/4 pass, unweakened. - The engine stops the running step, confirming the abort like any stop, gives runners `ABANDON_SETTLE_S`, then cancels every step that isn't terminal and expires its gates (`test_liveness.py::test_abandoning_a_run_stops_its_running_step_and_ends_the_rest`). The supervisor reaps the server of a run that is no longer live (`test_runtime.py::test_reaping_stops_the_server_of_a_run_that_is_over_and_frees_the_slot`). - **UI control**: - ✕ Abandon run in the stage header, shown only for a run that hasn't ended; - a confirmation that says in words what will stop; - an optional reason that goes on the log; - Keep running focused, and the destructive button last. - **New `e2e/abandon.spec.ts`**: Escape sends nothing; confirm sends the reason once with an id; a 409 keeps the dialog open with the server's message; an ended run has no Abandon. **Real osfd, end to end** (scratch state dir, a run whose `explore` step was running with a live `opencode serve` on :8900) - I clicked Abandon run on the page. The dialog read: *Stops the step that is working now: Explore the codebase. Cancels 22 steps that have not started.* I entered a reason and confirmed. - Results: - the run is `abandoned`; - `explore` was stopped and the 22 unstarted steps are `cancelled`; - the supervisor logged `opencode stopped, slot released`, and port 8900 was free; - the event carries `reason: "verifying Forgejo #9 from the page"` and `stopping: ["explore"]`. - Abandoning again returned 409 `already_ended`, and the abandoned run archived normally. The full Playwright suite passes: 37 passed, 3 skipped for live osfd.
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
cmoriarty/braid#9
No description provided.