Skip to content

CLI: Ctrl-C during a job follow should optionally cancel the server job (follow-up to #168) #170

Description

@Sootopolis

Follow-up to #168 / #169 (cancel-by-id shipped, merged 0b61977).

What shipped already (#169)

JobRunner.cancel(id) interrupts the job fiber (per-process runningFibers handle + a cancelRequested gate that keeps shutdown-interrupts out of the Cancelled state), the Cancelled JobRunStatus, POST /api/jobs/{jobId}/cancel (200 CancelResult / 404), and the ccas cancel <job-id> subcommand. A job cancelled out from under a follow already renders "<id> was cancelled" and exits 1 (JobFollower.finalExitCode, JobFollower.scala:180).

Goal

When following a job — ccas logs <id>, plus the auto-follow after ccas membership / history / recruit / stats — allow Ctrl-C to cancel the server job, not just detach. Must be opt-in: an accidental Ctrl-C must never kill a long run. The current contract is detach-by-default ("The detached job keeps running server-side", JobFollower.scala:12-13); preserve that as the default.

This is the last piece of the original IntelliJ graceful-stop analog from #168.

Decide first (UX)

  • Plain Ctrl-C default: detach (current) vs prompt "Cancel the server job? [y/N]" (default N) vs require an explicit --cancel-on-interrupt flag to even offer it. Lean: a --cancel-on-interrupt flag on the following commands; without it Ctrl-C detaches as today; with it, Ctrl-C prompts (or cancels directly — decide).
  • Whether the cancel path also chains the recruit deferred-confirm flow: a cancelled recruit already leaves candidates Deferred via RecruitmentApp.finalizeRun(interrupted = true) (RecruitmentApp.scala:327, skips promptConfirmation), and GET .../recruitment/found + POST .../recruitment/confirm already exist — so after cancelling a recruit follow we could offer "review/confirm the ones found so far".

Implementation notes

  • Ctrl-C is fiber interruption, not a typed error — it bypasses catchAll. Hook it with .onInterrupt in the follow path (JobFollower.followJob :29 / followWith :35).
  • A genuine mid-follow stream drop is already a typed StreamDropped → reconnect (CcasApiClient.scala:102-124, split on the connected flag), so it won't collide with the interrupt path — the .onInterrupt fires only on real fiber interruption.
  • The interrupt fires as the process tears down, so reading stdin in a finalizer is delicate. Gate on hasTty (Dispatcher.scala:266); on a non-TTY, just detach (never block a pipe). The cancel call itself is api.postEmpty[CancelResult](s"/api/jobs/$id/cancel") (client method exists, CcasApiClient.scala:89).

Touch points

  • src/main/scala/ccas/cli/JobFollower.scala — follow loop + .onInterrupt.
  • src/main/scala/ccas/cli/Dispatcher.scala — follow flows: followJob (:157), handleRecruitResult/handleRecruit (:273), followEachClub (:233), handleClubSingle (:150).
  • src/main/scala/ccas/cli/CliCommand.scala — add the flag to the following commands; then CompletionSpec.scala + regenerate completions/ccas.bash (the drift guard TestCcasCompletion enforces this).

Tests

  • Extend TestJobFollower — interrupt-during-follow fires / does not fire the cancel POST per the flag/default.
  • Consider extending the TestJobCancelWire harness (real server + real CcasApiClient) for the interrupt path.

Constraints

Format only touched files (repo isn't scalafmt-clean); named args for multi-line constructions; braces on match, parens on if/else; no braceless Scala 3. Don't ship without explicit approval.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:cliCLI binary (subcommands, completions, config, packaging)enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions