Skip to content

feat(server): cancel a running job by id#169

Merged
Sootopolis merged 1 commit into
mainfrom
wip
Jul 17, 2026
Merged

feat(server): cancel a running job by id#169
Sootopolis merged 1 commit into
mainfrom
wip

Conversation

@Sootopolis

Copy link
Copy Markdown
Owner

What

Adds cancellation of a running job by id — POST /api/jobs/{jobId}/cancel and the ccas cancel <job-id> CLI subcommand.

A CLI-submitted job couldn't be stopped once launched: the work runs as a fiber forked into the server's layer scope, so Ctrl-C in the CLI only detached the log follow while the server-side job ran to completion. (Contrast: an app run directly in IntelliJ is the root fiber, so Stop = interrupt.) There was no channel to reach that fiber — this adds one.

Mechanism

  • JobRunner retains a per-process handle to each job fiber (runningFibers, a Promise registered before the fork so release's de-register can't race the register).
  • cancel(id) registers the id in cancelRequested, then interruptForks the fiber (handler returns promptly). The job's own .onInterrupt records the new Cancelled state only if its id is in cancelRequested.
  • That gate is load-bearing: layerScope interrupts every in-flight job on server shutdown too — those stay Running so the next boot's markOrphansAsFailed records them as Failed/"Service restarted", not a spurious "Cancelled by operator". The interrupt-observing write also fixes a latent bug where an interrupted job bypassed foldZIO and left its row stuck at Running.
  • Best-effort/async: an in-flight uninterruptible blocking JDBC statement runs to completion before the interrupt lands; markCancelled's WHERE status = Running guard makes the write a no-op if the job reached a terminal state first. Per-process, so it inherits the single-server-per-DB assumption (JobRunner orphan-marking has no instance ownership — a 2nd server kills the 1st's live jobs #110/Rate limiter is per-process: N instances = N× the Chess.com per-IP request rate #111); an absent local fiber → 404, never a blind mark.
  • A job cancelled out from under an active ccas logs follow renders "was cancelled" and exits non-zero.

Also unifies all completed_at writers (updateStatus, markCancelled, markOrphansAsFailed) on a caller-supplied Clock.instant instead of SQL NOW(), so every job timestamp comes from the one testable app clock, coherent with started_at (no app-vs-DB host skew).

Docs (README + CLAUDE.md) and the committed bash completion updated.

Fixes

Fixes #168.

Testing

sbt test — 1059 pass. New coverage:

  • cancel interrupts a live job → Cancelled, against the real JobRunner.live (real forkIn/interrupt/onInterrupt, real DB).
  • A shutdown-style self-interrupt (no operator cancel) stays Running — proves the cancelRequested gate.
  • markCancelled Running-only guard (SQL); cancel route 200/404; CLI parse; completion drift; follow exit code.
  • End-to-end wire smoke (TestJobCancelWire): a real zio-http server on an ephemeral port serving real JobRoutes, driven by the real CcasApiClient over loopback — full ccas cancel path incl. the real fiber interrupt and the 404 branch.

Follow-up (separate PR)

Ctrl-C-during-ccas logs-follow → optional prompt-to-cancel (the closest analog to the IntelliJ graceful-stop flow), gated so an accidental Ctrl-C doesn't kill a long run.

🤖 Generated with Claude Code

A CLI-submitted job could not be stopped once launched: the work runs as a
fiber forked into the server's layer scope, so Ctrl-C in the CLI only detached
the log follow while the server-side job ran to completion. Unlike an app run
directly (IntelliJ Stop = root-fiber interrupt), there was no channel to reach
that fiber. This adds cancellation by id.

JobRunner now retains a per-process handle to each job fiber (`runningFibers`,
a Promise registered *before* the fork so `release`'s de-register can't race
ahead of the register). `cancel(id)` registers the id in `cancelRequested`,
then `interruptFork`s the fiber so the HTTP handler returns promptly. The
job's own `.onInterrupt` records the new `Cancelled` terminal state -- but ONLY
if its id is in `cancelRequested`. That gate is load-bearing: `layerScope`
interrupts every in-flight job on server shutdown too, and those must stay
`Running` so the next boot's `markOrphansAsFailed` records them as
`Failed`/"Service restarted", not a spurious "Cancelled by operator". The
interrupt-observing write also fixes a latent bug where an interrupted job
bypassed `foldZIO` and left its row stuck at `Running`.

Cancellation is best-effort/async: an in-flight uninterruptible blocking JDBC
statement runs to completion before the interrupt lands, and `markCancelled`'s
`WHERE status = Running` guard makes the terminal write a no-op if the job
reached a terminal state first. `cancel` is per-process, so it inherits the
single-server-per-DB assumption (#110/#111); an absent local fiber returns
404 rather than blindly marking a row that may be Running elsewhere.

Surface: `POST /api/jobs/{jobId}/cancel` (200 CancelResult / 404) and the
`ccas cancel <job-id>` subcommand. A job cancelled out from under an active
`ccas logs` follow renders "was cancelled" and exits non-zero.

Also unify all `completed_at` writers (`updateStatus`, `markCancelled`,
`markOrphansAsFailed`) on a caller-supplied `Clock.instant` instead of SQL
`NOW()`, so every job timestamp comes from the one testable app clock and is
coherent with `started_at` (no app-vs-DB host skew).

Tested: cancel interrupts a live job -> Cancelled (real JobRunner.live); the
shutdown-style self-interrupt stays Running; markCancelled guard; the cancel
route 200/404; CLI parse + completion drift; follow exit code; and an
end-to-end wire smoke (TestJobCancelWire) driving the real CcasApiClient over
a real HTTP server against real JobRoutes + JobRunner.live.

Fixes #168

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Sootopolis
Sootopolis merged commit 0b61977 into main Jul 17, 2026
1 check passed
Sootopolis added a commit that referenced this pull request Jul 19, 2026
#169 shipped cancel-by-id but Ctrl-C during a follow still only detached the
log stream while the server job ran on. That is the surprising behaviour: for
a mistyped club/alias you want out fast, and Ctrl-C == stop is the universal
CLI expectation. So a follow interrupt now cancels the server job by default.
No prompt -- the keystroke is the intent, and prompting would mean reading
stdin inside an interrupt finalizer as the process tears down (fragile). It is
also cheap to be wrong: CCAS jobs are idempotent/resumable, so an accidental
cancel just stops early and the re-run resumes; a cancelled recruit already
leaves candidates Deferred, so cancel never burns invites.

The `.onInterrupt` is attached OUTSIDE `.timeout`, so a genuine `maxWait`
expiry (which returns None on the success path) still detaches rather than
cancelling -- only a real fiber interruption fires the cancel POST, which is
`.ignore`d so a 404 (job already terminal) or a teardown transport error can't
turn a clean Ctrl-C into a stack trace. The notice routes through the follow's
own sink -- render-lock-safe `renderer.logLine` in bars mode, stderr otherwise
-- so it can't tear a live progress bar (the finalizer runs before withBars'
`renderer.clear`).

For "keep the data fresh, don't watch" (notably history, whose output is not
user-facing -- StatsApp consumes it), add `--detach` on membership/history/
stats: submit, print the job id, return; reattach with `ccas logs <id>`, which
replays the full log from offset 0 so nothing is lost. Not offered on recruit
(its invite delivery/confirmation needs the follow) or logs (which IS the
follow).

Making Ctrl-C-cancel easy exposes a rough edge: a resubmit of the same
(kind, club) that races the in-flight cancel would hit the unique-running
index and read the baffling "already running" for a job the operator just
killed -- the fiber flips to Cancelled only after its current blocking
statement finishes. `submit` now checks `cancelRequested` and, for a blocker
that is being cancelled, returns "finishing cancellation -- retry in a moment"
so the collision reads as self-resolving.

Tested: interrupt-during-follow fires the cancel POST and interrupt-during the
post-follow recruit confirm does not (stub follower); `--detach` parses and
defaults false on the three commands; a resubmit racing an in-flight cancel
gets the retry wording (real JobRunner.live); and an end-to-end wire test
drives the real JobFollower against a real HTTP server, interrupts it once the
follow fiber is Suspended, and asserts the job reaches Cancelled -- proving the
finalizer's POST lands over real HTTP during teardown. Completions regenerated
(TestCcasCompletion drift guard).

Fixes #170

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sootopolis added a commit that referenced this pull request Jul 19, 2026
#169 shipped cancel-by-id but Ctrl-C during a follow still only detached the
log stream while the server job ran on. That is the surprising behaviour: for
a mistyped club/alias you want out fast, and Ctrl-C == stop is the universal
CLI expectation. So a follow interrupt now cancels the server job by default.
No prompt -- the keystroke is the intent, and prompting would mean reading
stdin inside an interrupt finalizer as the process tears down (fragile). It is
also cheap to be wrong: CCAS jobs are idempotent/resumable, so an accidental
cancel just stops early and the re-run resumes; a cancelled recruit already
leaves candidates Deferred, so cancel never burns invites.

The `.onInterrupt` is attached OUTSIDE `.timeout`, so a genuine `maxWait`
expiry (which returns None on the success path) still detaches rather than
cancelling -- only a real fiber interruption fires the cancel POST, which is
`.ignore`d so a 404 (job already terminal) or a teardown transport error can't
turn a clean Ctrl-C into a stack trace. The notice routes through the follow's
own sink -- render-lock-safe `renderer.logLine` in bars mode, stderr otherwise
-- so it can't tear a live progress bar (the finalizer runs before withBars'
`renderer.clear`).

For "keep the data fresh, don't watch" (notably history, whose output is not
user-facing -- StatsApp consumes it), add `--detach` on membership/history/
stats: submit, print the job id, return; reattach with `ccas logs <id>`, which
replays the full log from offset 0 so nothing is lost. Not offered on recruit
(its invite delivery/confirmation needs the follow) or logs (which IS the
follow).

Making Ctrl-C-cancel easy exposes a rough edge: a resubmit of the same
(kind, club) that races the in-flight cancel would hit the unique-running
index and read the baffling "already running" for a job the operator just
killed -- the fiber flips to Cancelled only after its current blocking
statement finishes. `submit` now checks `cancelRequested` and, for a blocker
that is being cancelled, returns "finishing cancellation -- retry in a moment"
so the collision reads as self-resolving.

Tested: interrupt-during-follow fires the cancel POST and interrupt-during the
post-follow recruit confirm does not (stub follower); `--detach` parses and
defaults false on the three commands; a resubmit racing an in-flight cancel
gets the retry wording (real JobRunner.live); and an end-to-end wire test
drives the real JobFollower against a real HTTP server, interrupts it once the
follow fiber is Suspended, and asserts the job reaches Cancelled -- proving the
finalizer's POST lands over real HTTP during teardown. Completions regenerated
(TestCcasCompletion drift guard).

Fixes #170

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add job cancellation: CLI cannot stop a running job (unlike a directly-run app)

1 participant