Problem
There is no way to cancel a running job submitted via the CLI. Once ccas recruit (or any job subcommand) submits, the work runs to completion no matter what the operator does — Ctrl-C in the CLI only detaches the log follow; the server-side job fiber runs on.
This is asymmetric with running an app directly (e.g. RecruitmentApp in IntelliJ), where the app is the JVM's root fiber and the Stop button (SIGINT) interrupts it, running its .onInterrupt finalizers.
Why the CLI can't cancel today
The CLI is a separate process talking to the server over HTTP. ccas recruit POSTs /api/jobs/recruitment, gets a jobId, then merely follows the log stream. The real work runs as a fiber forkIn(layerScope) inside the server JVM (JobRunner.scala:184), owned by the server's layer scope — not the terminal. Ctrl-C interrupts only the CLI's follow fiber and sends nothing to the server (by design — the detach messaging says "job keeps running, reattach with ccas logs", JobFollower.scala:155-169).
The gap (concrete missing parts)
- No
JobRunId → Fiber handle. submit discards the result of forkIn(layerScope) (JobRunner.scala:147-184). The two Refs keyed by JobRunId hold a completion Promise and a progress BarChannel only (JobRunner.scala:74,77) — never the fiber. There is no address to interrupt.
- No
cancel method on the JobRunner trait (submit/status/recentJobs/logStream/progressStream).
- No terminal state for cancellation.
JobRunStatus = Running | Completed | Failed (JobRunStatus.scala:5-7). No Cancelled.
- Interrupt is never observed as a terminal transition.
runJob ends in foldZIO(onFailure, onSuccess) (JobRunner.scala:214), which catches only the typed Throwable channel. Interruption is a Cause, bypasses both branches, and the job_run row is left stuck at Running until the next server boot's JobRun.markOrphansAsFailed sweep (JobRunner.scala:87, JobRun.markOrphansAsFailed). The .ensuring(release) at JobRunner.scala:179 fires on interrupt but only closes the file sink / completion promise / channel Refs — it never touches the DB row.
- No HTTP endpoint, no CLI subcommand.
Recruitment: the invite prompt is already handled, and cancel reuses it
Worth calling out because it removes work. When RecruitmentApp is interrupted, .onInterrupt runs finalizeRun(interrupted = true) (RecruitmentApp.scala:326-340). Note that this path skips the interactive invite prompt — line 382 short-circuits if (interrupted || found.isEmpty) … — and leaves the candidates found so far persisted as Deferred rows. The Y/n promptConfirmation only fires on normal completion, and it reads process stdin (StdIn.readLine, RecruitmentApp.scala:446), which cannot cross the CLI→server boundary anyway.
That is fine, because the Deferred candidates left by an interrupt feed the existing async delivery flow: GET /api/jobs/{id}/recruitment/found + POST .../recruitment/confirm. So cancel-a-recruit-job + existing confirm endpoint ≈ the graceful-stop-then-invite behaviour, decoupled from stdin — no new prompt machinery required. The recruit interrupt finalizer is also already stdin-free, so it is safe to trigger server-side.
Proposed approach
- (a) Retain fiber handle. Add
fibers: Ref[Map[JobRunId, Fiber.Runtime[?, Unit]]] next to completions/jobChannels. Capture the forkIn(layerScope) result inside the existing uninterruptibleMask and register it; add fibers removal to the existing release block (JobRunner.scala:159-164) so the map self-cleans on normal exit.
- (b)
cancel method. def cancel(id: JobRunId): ZIO[…, ?, Boolean]. Look up the fiber; present → fork fiber.interrupt (don't block the handler on the interrupt landing), then mark the row terminal. Absent → return false (already terminal, or owned by another server — see caveats).
- (c) New
Cancelled state (preferred over reusing Failed — keeps operator-initiated stops distinct from real failures in recentJobs and the "Service restarted" orphan sweep). Add to the enum; persists via EnumSql. Companion fix: the terminal write must come from an interrupt-observing hook, not foldZIO. Switch runJob's tail to .onExit and, on Exit.Failure(cause) where cause.isInterrupted, write Cancelled + completed_at. Add JobRun.markCancelled mirroring markOrphansAsFailed.
- (d) HTTP.
POST /api/jobs/{jobId}/cancel (POST, not DELETE — the row is retained; this is a state transition). Mirror the GET /api/jobs/{jobId} handler shape: 200 with a small CancelResult, 404 when unknown.
- (e) CLI. A
ccas cancel <id> subcommand (new CliCommand case + Dispatcher branch). Optionally, offer cancel on Ctrl-C-during-follow via a JobFollower.onInterrupt prompt — but keep detach-as-default (an accidental Ctrl-C must not kill a long run); gate behind --cancel-on-interrupt or an explicit y/N.
Caveats
Scope
- Minimal (~half day): reuse
Failed (no schema change), add fiber Ref + capture, cancel + onExit terminal write, POST .../cancel, ccas cancel <id>. ~4 files (JobRunner, JobRoutes, CliCommand/Dispatcher, JobRun).
- Full (~1–1.5 days): distinct
Cancelled state + surfacing in JobStatusResponse, Ctrl-C-during-follow prompt, tests. Fiddly parts are only the fiber capture and the interrupt-vs-foldZIO terminal write; the rest is mechanical and follows existing route/CLI/table patterns. No new deps.
Related
Problem
There is no way to cancel a running job submitted via the CLI. Once
ccas recruit(or any job subcommand) submits, the work runs to completion no matter what the operator does — Ctrl-C in the CLI only detaches the log follow; the server-side job fiber runs on.This is asymmetric with running an app directly (e.g.
RecruitmentAppin IntelliJ), where the app is the JVM's root fiber and the Stop button (SIGINT) interrupts it, running its.onInterruptfinalizers.Why the CLI can't cancel today
The CLI is a separate process talking to the server over HTTP.
ccas recruitPOSTs/api/jobs/recruitment, gets ajobId, then merely follows the log stream. The real work runs as a fiberforkIn(layerScope)inside the server JVM (JobRunner.scala:184), owned by the server's layer scope — not the terminal. Ctrl-C interrupts only the CLI's follow fiber and sends nothing to the server (by design — the detach messaging says "job keeps running, reattach withccas logs",JobFollower.scala:155-169).The gap (concrete missing parts)
JobRunId → Fiberhandle.submitdiscards the result offorkIn(layerScope)(JobRunner.scala:147-184). The two Refs keyed byJobRunIdhold a completionPromiseand a progressBarChannelonly (JobRunner.scala:74,77) — never the fiber. There is no address to interrupt.cancelmethod on theJobRunnertrait (submit/status/recentJobs/logStream/progressStream).JobRunStatus = Running | Completed | Failed(JobRunStatus.scala:5-7). NoCancelled.runJobends infoldZIO(onFailure, onSuccess)(JobRunner.scala:214), which catches only the typedThrowablechannel. Interruption is aCause, bypasses both branches, and thejob_runrow is left stuck atRunninguntil the next server boot'sJobRun.markOrphansAsFailedsweep (JobRunner.scala:87,JobRun.markOrphansAsFailed). The.ensuring(release)atJobRunner.scala:179fires on interrupt but only closes the file sink / completion promise / channel Refs — it never touches the DB row.Recruitment: the invite prompt is already handled, and cancel reuses it
Worth calling out because it removes work. When
RecruitmentAppis interrupted,.onInterruptrunsfinalizeRun(interrupted = true)(RecruitmentApp.scala:326-340). Note that this path skips the interactive invite prompt — line 382 short-circuitsif (interrupted || found.isEmpty) …— and leaves the candidates found so far persisted as Deferred rows. The Y/npromptConfirmationonly fires on normal completion, and it reads process stdin (StdIn.readLine,RecruitmentApp.scala:446), which cannot cross the CLI→server boundary anyway.That is fine, because the Deferred candidates left by an interrupt feed the existing async delivery flow:
GET /api/jobs/{id}/recruitment/found+POST .../recruitment/confirm. So cancel-a-recruit-job + existing confirm endpoint ≈ the graceful-stop-then-invite behaviour, decoupled from stdin — no new prompt machinery required. The recruit interrupt finalizer is also already stdin-free, so it is safe to trigger server-side.Proposed approach
fibers: Ref[Map[JobRunId, Fiber.Runtime[?, Unit]]]next tocompletions/jobChannels. Capture theforkIn(layerScope)result inside the existinguninterruptibleMaskand register it; addfibersremoval to the existingreleaseblock (JobRunner.scala:159-164) so the map self-cleans on normal exit.cancelmethod.def cancel(id: JobRunId): ZIO[…, ?, Boolean]. Look up the fiber; present → forkfiber.interrupt(don't block the handler on the interrupt landing), then mark the row terminal. Absent → returnfalse(already terminal, or owned by another server — see caveats).Cancelledstate (preferred over reusingFailed— keeps operator-initiated stops distinct from real failures inrecentJobsand the "Service restarted" orphan sweep). Add to the enum; persists viaEnumSql. Companion fix: the terminal write must come from an interrupt-observing hook, notfoldZIO. SwitchrunJob's tail to.onExitand, onExit.Failure(cause)wherecause.isInterrupted, writeCancelled+completed_at. AddJobRun.markCancelledmirroringmarkOrphansAsFailed.POST /api/jobs/{jobId}/cancel(POST, not DELETE — the row is retained; this is a state transition). Mirror theGET /api/jobs/{jobId}handler shape: 200 with a smallCancelResult, 404 when unknown.ccas cancel <id>subcommand (newCliCommandcase +Dispatcherbranch). Optionally, offer cancel on Ctrl-C-during-follow via aJobFollower.onInterruptprompt — but keep detach-as-default (an accidental Ctrl-C must not kill a long run); gate behind--cancel-on-interruptor an explicit y/N.Caveats
ZIO.attemptBlocking(not…Interrupt) inPostgresClient, so an in-flight statement runs to completion; interrupt is honoured between statements (or when the NeonsocketTimeoutfires).HistoryApp.scala:353has a bounded.uninterruptiblefinalize block (see HistoryApp Phase 4 finalization is not interrupt-safe — interrupt after the wave block strands a non-terminal history_run #137). UX copy should say "cancellation requested."fibersmap is per-process;fiber.interruptonly reaches this JVM's fiber. Fine under the documented single-server-per-DB model (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). If the fiber is absent locally, return 404 / "not owned here" — do not blindly markCancelled, or a row still genuinelyRunningon another server would be flipped.Scope
Failed(no schema change), add fiber Ref + capture,cancel+onExitterminal write,POST .../cancel,ccas cancel <id>. ~4 files (JobRunner,JobRoutes,CliCommand/Dispatcher,JobRun).Cancelledstate + surfacing inJobStatusResponse, Ctrl-C-during-follow prompt, tests. Fiddly parts are only the fiber capture and the interrupt-vs-foldZIOterminal write; the rest is mechanical and follows existing route/CLI/table patterns. No new deps.Related
onExitterminal-write fix here is the general form).