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.
Follow-up to #168 / #169 (cancel-by-id shipped, merged
0b61977).What shipped already (#169)
JobRunner.cancel(id)interrupts the job fiber (per-processrunningFibershandle + acancelRequestedgate that keeps shutdown-interrupts out of theCancelledstate), theCancelledJobRunStatus,POST /api/jobs/{jobId}/cancel(200CancelResult/ 404), and theccas 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 afterccas 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)
"Cancel the server job? [y/N]"(default N) vs require an explicit--cancel-on-interruptflag to even offer it. Lean: a--cancel-on-interruptflag on the following commands; without it Ctrl-C detaches as today; with it, Ctrl-C prompts (or cancels directly — decide).RecruitmentApp.finalizeRun(interrupted = true)(RecruitmentApp.scala:327, skipspromptConfirmation), andGET .../recruitment/found+POST .../recruitment/confirmalready exist — so after cancelling a recruit follow we could offer "review/confirm the ones found so far".Implementation notes
catchAll. Hook it with.onInterruptin the follow path (JobFollower.followJob:29/followWith:35).StreamDropped→ reconnect (CcasApiClient.scala:102-124, split on theconnectedflag), so it won't collide with the interrupt path — the.onInterruptfires only on real fiber interruption.hasTty(Dispatcher.scala:266); on a non-TTY, just detach (never block a pipe). The cancel call itself isapi.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; thenCompletionSpec.scala+ regeneratecompletions/ccas.bash(the drift guardTestCcasCompletionenforces this).Tests
TestJobFollower— interrupt-during-follow fires / does not fire the cancel POST per the flag/default.TestJobCancelWireharness (real server + realCcasApiClient) for the interrupt path.Constraints
Format only touched files (repo isn't scalafmt-clean); named args for multi-line constructions; braces on
match, parens onif/else; no braceless Scala 3. Don't ship without explicit approval.