Pressing Stop Does Not Stop the Work
Tomohiro Iida · Published August 25, 2026 · Updated August 25, 2026
When an AI agent is working on your behalf, there are moments when you want it to stop. Pressing Stop in the chat ends the response generation. The GitHub Actions runs, cron jobs, deployments, and database migrations that the conversation started usually keep going. Stopping a conversation and cancelling an external process are two different operations, and the state of a stop is not a single boolean.
Key takeaways
- The chat Stop button ends generation only. Dispatched workflows, crons, and deployments continue.
- Model a stop with seven states: NOT_DISPATCHED, RUNNING, CANCEL_REQUESTED, CANCEL_CONFIRMED, SUCCEEDED, FAILED, and UNKNOWN.
- Requesting a cancellation and having a cancellation confirmed are different facts and must not share one state.
- Observation continues after the stop decision, and rollback is a separate operation from stopping.
- UNKNOWN is treated as fail-closed: assume the work is still running or already applied and start no further mutation.
Incident card
| Field | Value |
|---|---|
| Incident | A chat Stop and the cancellation of an external process were treated as the same operation. |
| Symptom | Workflows, crons, and deploys kept advancing after the conversation ended, and nobody observed the final state. |
| False assumption | Pressing Stop also stops the work that the conversation started. |
| Root cause | Conversation stop, cancel request, cancel confirmation, observation, and rollback were held as one state. |
| Immediate fix | Do not mark work complete until the terminal state of every dispatched job has been re-read. |
| System fix | Hold seven separate states and push UNKNOWN to the fail-closed side. |
| Remaining risk | A delivered cancel request does not prove that no side effect occurred, and rollback remains a further operation. |
What we observed
- Ending a conversation does not stop a GitHub Actions run that has already started.
- The cron entries in vercel.json fire on their own schedule, independent of any conversation.
- Production workflows set concurrency.cancel-in-progress to false, so a later run does not interrupt them.
- After the conversation ends, there is a window in which nobody is observing the terminal state.
The false assumption: stop is one operation
| Operation | What it stops | What it does not stop |
|---|---|---|
| Stop button | The response being generated | Dispatched jobs, crons, deployments |
| Ending the conversation | The session | External system state |
| Workflow cancel | The remaining steps | Side effects of completed steps |
| Deploy cancel | A build before delivery | A deployment already delivered |
| Migration cancel | Migrations not yet applied | DDL already applied |
| Rollback | Nothing | It moves state back; it is not a stop |
Root cause: stop was held as a two-value state
Treating a stop as stopped or not stopped collapses three different situations into one value: a cancellation was requested but delivery is unconfirmed, a cancellation was confirmed by the provider, and the state cannot be observed at all. The third is the dangerous one. Reading “unobservable” as “probably stopped” means the next operation starts on top of a process that is still running.
Immediate fix: no completion before a terminal state
- Enumerate the external processes this conversation dispatched.
- Re-read each process state from the provider API rather than from the conversation.
- Mark complete only what reached SUCCEEDED, FAILED, or CANCEL_CONFIRMED.
- Record anything that cannot be re-read as UNKNOWN.
- Start no further mutation while any UNKNOWN remains.
System fix: seven states
| State | Meaning | What may happen next |
|---|---|---|
| NOT_DISPATCHED | Never handed to an external system | Discard; no cancellation needed |
| RUNNING | Executing now | A cancel request may be sent |
| CANCEL_REQUESTED | Request sent; delivery and effect unconfirmed | Keep observing |
| CANCEL_CONFIRMED | Cancellation confirmed by the provider | Check separately for side effects |
| SUCCEEDED | Finished normally | Consider rollback |
| FAILED | Ended in failure | Check for partial application |
| UNKNOWN | State cannot be observed | Fail closed; start no mutation |
Separating CANCEL_REQUESTED from CANCEL_CONFIRMED is the core of the model. Sending the request is a fact on our side; the cancellation is a fact on the provider side. Merging them means the moment a request is issued, the work is treated as stopped.
- STOP(conversation) ends generation only and sends nothing to an external process.
- CANCEL(process_id) moves the process to CANCEL_REQUESTED and confirms nothing.
- OBSERVE(process_id) re-reads the terminal state and continues after the stop decision.
- ROLLBACK(target) is a separate operation that requires approval and evidence.
- If the state is UNKNOWN, treat it as running or already applied and perform no mutation.
Observation continues after the stop
If observation ends when the stop decision is made, the process disappears from the record as “presumably stopped”. In this repository, Stop is an inspection point rather than a terminal state: .claude/settings.json registers three Stop hooks, and .claude/hooks/autonomous-stop-guard.mjs refuses a stop that hands agent-executable waiting, polling, or cleanup back to the user. The same hook treats any background task that is not completed, failed, or cancelled as still active.
Rollback is a different operation
.github/workflows/deploy-signal-production.yml records a deployment boundary as soon as the deploy job succeeds. The step summary states DEPLOYED with the subject SHA and notes that the following job is post-deploy verification and that a later failure does not undo the deployment. A red workflow with an updated production is therefore a valid state, and reading red as “not released” leads to a blind rerun and a double release.
Rolling back uses a dedicated recovery workflow that ships no new code. .github/workflows/production-rollback.yml requires a confirmation string, runs only from main, and does not trust the requested target: it re-reads the deployment metadata from Vercel. A rollback target is valid only when its source commit is in the history of origin/main and it was previously served as production. Rolling back to an unmerged preview would be a bypass, not a recovery.
Some processes are not interruptible by design
| Workflow family | cancel-in-progress | Reason |
|---|---|---|
| Production deploy, migration, rollback | false | Killing them midway damages external state |
| Pull request CI and exact-head checks | true | Stopping midway does not change external state |
Production deploy and rollback share the signal-production concurrency group, while production database migration is serialised in its own production-db-migration group. Neither is interrupted by a newer run. The groups are separate because a deploy and a migration have no reason to wait for each other; putting them in one group makes one block the other. "Do not interrupt" and "do not queue behind" are separate decisions.
.github/workflows/production-migration.yml is manual only, requires a confirmation string, and takes the migrations you intend to apply as input; if that list disagrees with what is actually unapplied, it aborts. The entry condition is narrowed before the stopping behaviour is considered.
Reusable stop matrix
| What you want to stop | Operation to use | State to confirm |
|---|---|---|
| AI response generation | Stop | Conversation side only; external state unchanged |
| A running CI job | Workflow cancel | Run conclusion is cancelled |
| A release in progress | Abort before delivery | Once delivered, switch to rollback |
| A migration in progress | Abort input | Applied migration numbers in the database |
| A release already delivered | Rollback | Target provenance and production history |
Evidence
| Claim | Class | Source |
|---|---|---|
| Production deploy, migration, and rollback are not interrupted by a newer run. | IMPLEMENTED | concurrency.cancel-in-progress false in the three workflows |
| A failure after the deploy job does not undo the deployment. | IMPLEMENTED | Deployment boundary record in deploy-signal-production.yml |
| Rollback requires a confirmation string, main, and provenance verification. | IMPLEMENTED | .github/workflows/production-rollback.yml |
| Migration is manual and checks the expected migration list. | IMPLEMENTED | .github/workflows/production-migration.yml |
| Git-triggered deployment is disabled on every branch. | IMPLEMENTED | git.deploymentEnabled in vercel.json |
| Cron jobs fire independently of any conversation. | IMPLEMENTED | crons in vercel.json |
| Stop passes through three registered hooks. | IMPLEMENTED | Stop entry in .claude/settings.json |
| Background tasks that are not terminal are treated as active. | IMPLEMENTED | .claude/hooks/autonomous-stop-guard.mjs |
| There was a window with no observation of the terminal state. | OBSERVED | Operational observation |
| The effect of applying the seven-state model everywhere. | INFERRED | The state definitions are in use; the effect is unmeasured. |
| Automatic confirmation that a cancel request was delivered. | PROPOSED | Currently a manual observation step. |
Limitations
- The state names are Netsujo operating vocabulary and do not map one to one onto provider APIs.
- The number of incidents or hours avoided is unmeasured; this article covers only the separation of states and the observation procedure.
- The setup assumes GitHub Actions, Vercel, and database migrations. A different execution platform exposes different observable states.
- A concurrency setting decides only whether a newer run interrupts an older one; it does not forbid a manual cancel.
Remaining risk
- A confirmed cancellation still does not prove that no partial side effect remains.
- Failing closed on UNKNOWN means work that has actually finished can still block the next step, so observability directly affects delivery speed.
- What can be rolled back depends on production history: without recorded provenance, a deployment cannot be chosen as a target.
- Processes that run independently of any conversation, such as crons, are the easiest to omit from the stop design, and an omission is not caught by configuration.
Frequently asked questions
- Why does work continue after I press Stop?
- Stop acts on the conversation and sends nothing to a dispatched external process. Actions runs, crons, and deployments each advance on their provider. Stopping them requires a separate cancel operation per target.
- Does cancelling the workflow restore production?
- No. Cancelling stops the remaining steps while the side effects of completed steps remain. If the release was already delivered, the required operation is a rollback through the dedicated recovery workflow.
- Does a red workflow mean production was not updated?
- Not necessarily. When the deploy job succeeds and post-deploy QA fails, production is updated and the workflow is red. Release status is judged by the delivered deployment and its subject SHA, not by the colour of the run.
- What do we do when the state cannot be observed?
- Record it as UNKNOWN and treat it as running or already applied. Do not start the next mutation. Being unable to observe is not evidence that something stopped.
- Should every job be interruptible by a newer run?
- Only the checking jobs. Anything whose interruption leaves external state intact is safe to cancel in progress. Production deploy, migration, and rollback are serialised in one concurrency group instead.
Previous: the same work item, different chat namesEpisode 03 covers how work is identified when it moves between tools.
Next: trust the exact SHA, not the pull request numberEpisode 05 aligns the code that was verified with the code that is operated on.
AI agent development and operations incident logThe series hub lists all twelve episodes and the order to read them in.
We separate stopping, cancelling, observing, and rolling back into distinct operations with recorded state.
Talk to Netsujo about AI development and operations design