feat(jobs): refuse to mutate alongside another operation's job container - #182
Conversation
A sealed job run executes attached over SSH. When the client goes away the container keeps running — the daemon owns it — while nothing on the workstation does. The application lock is then either dropped outright (Ctrl-C releases it on its own background context) or expires on a dead heartbeat, and the next deploy or job run proceeds alongside a live data-changing container. The check asks Docker, not the journal. A journal records what a client managed to write, and the failure this exists for is a client that did not write: a run that recorded its own interruption looks finished on paper while its container keeps going, a re-run plan appends a second invocation to the same journal, and a deploy-phase gate container never appears in a job-run journal at all. One `docker ps` sees all three. Matching is on operation AND epoch. A sealed plan carries one operation id for its whole life and is re-runnable, and AcquireLock hands the lock straight back to a caller presenting the id already written in it — so matching the operation alone would let a second run of one plan exempt the container its own earlier run left behind. A container carrying no epoch cannot be shown to belong to this invocation and is not exempt from it. Placement follows the diagnostics: after the staleness checks so a stale plan is told it is stale, and after preflight so an unreachable daemon is reported by the check that exists for that, while still preceding every mutation. Both call sites are pinned by tests that drive the real entry points. The unit tests behind them passed with the calls deleted. Refs #179. Claude-Session: https://claude.ai/code/session_01JaxHfqFZk8GdrBNbtQZ6c2
There was a problem hiding this comment.
🟡 Changes recommended
The new refusal error messages currently suggest docker rm -f with truncated container IDs (can be ambiguous), and the new refusal test file includes misleading/unfinished comments that should be cleaned up.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR adds a safety gate to prevent ob deploy and sealed manual ob job run from mutating the application while a one-off job container from another operation (or another epoch of the same operation) is still running on the host, using Docker labels (ob.operation, ob.epoch) as the source of truth.
Changes:
- Add a Docker-based probe (
docker ps --filter label=ob.operation) and refusal predicate to detect foreign / prior-epoch job containers. - Invoke this refusal in both deploy (
deployCore) and manual job execution (RunJobWithJournalID) before any mutation. - Add unit tests for the predicate and integration-style tests ensuring deploy/job-run stop before starting when a foreign container is present.
File summaries
| File | Description |
|---|---|
| internal/onebox/service_test.go | Update fakes to handle the new docker ps label probe used by the refusal gate. |
| internal/engine/job.go | Call the refusal gate after plan staleness checks and before creating a job container. |
| internal/engine/job_test.go | Add an integration-style test ensuring RunJobWithJournalID refuses before starting the job. |
| internal/engine/job_reconcile.go | Implement the Docker-based job-container probe and refusal logic. |
| internal/engine/job_reconcile_test.go | Add unit tests for the refusal predicate and parsing edge cases. |
| internal/engine/deploy.go | Call the refusal gate after preflight and before any deploy mutations / gate jobs. |
| internal/engine/deploy_test.go | Add an integration-style test ensuring deploy refuses before rolling workloads when a foreign container exists. |
Review details
Suppressed comments (1)
internal/engine/job_reconcile_test.go:92
- The trailing comment block is unfinished and refers to recording operators, which isn’t part of this PR’s refusal-only behavior. Leaving it here reads like a missing test or incomplete change; please remove it (or add the corresponding implementation/tests in the appropriate PR).
// The reconciling operator must not be recorded as the interrupted run's.
// Audit takes the last non-empty operator in an epoch group, so stamping it
// here rewrites the row to name whoever deployed next.
- Files reviewed: 7/7 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…tainer id Splitting the reconciliation out left this file named for work it no longer contains, two helpers named after it, and an orphaned doc comment sitting above an unrelated test describing a function that is gone. Renamed to what it is: the running job containers of an operation. The refusals suggested `docker rm -f` with a twelve-character prefix. That is what docker prints and it is usually unique, which is the wrong property for a command an operator is expected to paste against a container they are about to destroy. The prose still abbreviates; the command carries the whole id. Claude-Session: https://claude.ai/code/session_01JaxHfqFZk8GdrBNbtQZ6c2
There was a problem hiding this comment.
🔵 Needs a closer look
The new refusal error message asserts “no process owning it” without evidence, which can mislead operators in realistic lock-reclaim scenarios.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
internal/engine/job_containers.go:53
- The refusal message claims the foreign container is running "with no process owning it", but this code only knows that the container exists and its labels; it does not (and cannot) prove whether some process is still attached/controlling it. This can mislead operators in cases where the workload is still actively driven but the lock was reclaimed.
- Files reviewed: 7/7 changed files
- Comments generated: 0 new
- Review effort level: Lite
The message said a running job container had "no process owning it". Nothing checks that. A container can be running while its operation is very much in progress — a lock reclaimed by the same operation id, or an operator watching it in another terminal — and telling them otherwise invites them to destroy work that is proceeding normally. It now states what is known and leaves the judgement where it belongs. Claude-Session: https://claude.ai/code/session_01JaxHfqFZk8GdrBNbtQZ6c2
There was a problem hiding this comment.
🔵 Needs a closer look
The refusal error message currently asserts “earlier run” even when the epoch label is missing/unknown, which can misstate what’s known about the running container.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
internal/engine/job_containers.go:40
- When a container has the same operation ID but no epoch label, the error message still states it was from “an earlier run”. With an unknown epoch, the code can’t reliably distinguish whether it was an earlier invocation or simply an unlabeled/legacy container, so the message should avoid asserting “earlier” in that case while still refusing.
- Files reviewed: 7/7 changed files
- Comments generated: 0 new
- Review effort level: Lite
The same-operation refusal called the container an "earlier run" whichever epoch it carried, and "unknown" when it carried none. A differing epoch says the container belongs to some other invocation, not which one or when — epochs are not ordered against each other here — and a missing epoch says only that it cannot be placed at all. Both now say that, and the one with an epoch prints both epochs so an operator can tell the two invocations apart. Claude-Session: https://claude.ai/code/session_01JaxHfqFZk8GdrBNbtQZ6c2
There was a problem hiding this comment.
🟢 Approval recommended
The behavioral change is narrowly scoped, uses a Docker-grounded predicate with good unit/integration coverage, and no functional defects were found in the new refusal logic.
Review details
Suppressed comments (2)
Previously missed (1) — in code that hasn't changed since the last review.
internal/onebox/service_test.go:86
- This fake match is overly broad: it will also match commands like
docker ps -q --filter label='ob.operation=<id>'(used by jobContainerRunning), returning an empty result and potentially masking behavior in this test. Make the match specific to the newdocker ps --filter label='ob.operation' --format ...probe.
This issue also appears on line 273 of the same file.
internal/onebox/service_test.go:274
- This fake match is overly broad: it will also match commands like
docker ps -q --filter label='ob.operation=<id>', returning an empty result and potentially hiding bugs. Match the specificdocker ps --filter label='ob.operation' --format ...probe instead.
case strings.Contains(command, "label='ob.operation'"):
return transport.Result{Stdout: "\n"}, true
- Files reviewed: 7/7 changed files
- Comments generated: 0 new
- Review effort level: Lite
Refusing released the application lock on the way out. On the job path the refusal returned before the flag that holds it could be set; on the deploy path the release was unconditional. So a run that found a live data-changing container told the operator about it and then handed the host to whatever came next — with the lock the interrupted run had been holding gone as well. That is worse than not refusing at all: without this check the second run would at least have kept a lock. Both paths now keep it, which is the state the lock exists for. Also corrects the comment above the docker ps parsing. It justified TrimRight by claiming a trimmed line would be unparseable, which is not true — the fields are trimmed individually either way. What TrimRight actually avoids is the inverse: leading whitespace cutting to an empty id and dropping the line in silence, which is the one failure this check cannot afford. Claude-Session: https://claude.ai/code/session_01JaxHfqFZk8GdrBNbtQZ6c2
There was a problem hiding this comment.
🟡 Changes recommended
The new docker ps parsing can silently ignore a running container on unexpected leading whitespace, and the updated test fake matchers are overly broad and may mask future label-filter behaviors.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
internal/onebox/service_test.go:274
- This matcher is also too broad here:
strings.Contains(command, "label='ob.operation'")will intercept both the key-only filter and key=value filters (e.g.label='ob.operation=<id>'). Make it specific to the key-only--filter label='ob.operation'probe so other label queries can still be simulated accurately if added later.
case strings.Contains(command, "label='ob.operation'"):
return transport.Result{Stdout: "\n"}, true
- Files reviewed: 7/7 changed files
- Comments generated: 2
- Review effort level: Lite
The previous commit's comment claimed TrimRight avoided leading whitespace cutting to an empty id. It does not — TrimRight strips the newline and nothing else, so a padded line still yielded an empty id and was skipped in silence. A container nobody can see is the one failure this check cannot afford, and the comment asserting otherwise made it harder to spot. Leading whitespace is now stripped and the line end only of its newline, which keeps the trailing space that makes an empty label parse as empty rather than absent. Both cases are pinned: a padded line still refuses, an empty label still refuses. The onebox fakes matched any command mentioning the label, which would also swallow a future query of a different shape. They now match the probe they mean to answer. Claude-Session: https://claude.ai/code/session_01JaxHfqFZk8GdrBNbtQZ6c2
…ing to match This parsing has been wrong twice — once dropping a container whose label was empty, once dropping one whose line was padded — and each fix was checked against a single example, which is why the second bug survived the first fix. A table now states the whole shape: no output, blank lines, several containers, each label absent in turn, padding, a carriage return, and no trailing newline. Writing it exposed a third error, this time in my own reasoning rather than the code. The previous commit justified splitting TrimLeft from TrimRight by claiming the trailing separator was what let an empty label parse as empty. It is not: fields are cut and trimmed individually, and Cut yields empty for a separator that is not there, so a plain TrimSpace behaves identically. Verified by mutation — swapping them fails nothing. The parsing is now the simpler form and the comment says what is actually true. Also covers a daemon that cannot answer, and output that is not a container id; neither may read as an empty host. Claude-Session: https://claude.ai/code/session_01JaxHfqFZk8GdrBNbtQZ6c2
There was a problem hiding this comment.
🔵 Needs a closer look
A refusal path in RunJobWithJournalID now reuses the “interrupted run left its container alive” lock-hold flag, causing a misleading deferred warning that doesn’t match the refusal scenario.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
internal/engine/job.go:97
- When
refuseForeignJobContainersfails, this setsholdLockForLiveContainer = true, which triggers the deferred warning "operation ... was interrupted while its container is still running". In this refusal path the operation wasn’t interrupted and the live container may be from a different operation, so the warning is misleading (and the suggesteddocker ps --filter label=ob.operation=<this operation>won’t help locate the foreign container you just refused on). Consider separating the concepts of “keep the lock” vs “interrupted run left this operation’s container alive”, or gating the warnf on the interrupted-run case only.
- Files reviewed: 7/7 changed files
- Comments generated: 0 new
- Review effort level: Lite
Two different situations now hold the application lock on the way out: a run this process started was interrupted with its container still alive, and a run that never started because someone else's container is. They shared a boolean, so refusing printed the interrupted-run warning — telling an operator their run was interrupted when it had not begun, and pointing them at a container label that is not the one running. The flag is a reason now. Each path sets its own, the deferred release prints it, and an empty reason still means release. Both are pinned: removing either reason fails a test. Claude-Session: https://claude.ai/code/session_01JaxHfqFZk8GdrBNbtQZ6c2
There was a problem hiding this comment.
🟡 Changes recommended
The new lock-hold warning message in RunJobWithJournalID can be misleading on non-container errors (e.g., inability to query Docker), and should be made accurate before merging.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 7/7 changed files
- Comments generated: 1
- Review effort level: Lite
… be asked The refusal fires for two reasons — a job container is running, or `docker ps` could not be run or parsed — and the narration asserted the first for both. An operator whose daemon was unreachable was told something was mutating alongside a container that had never been observed. The lock is kept either way, and for the same reason: an unanswered question is not an answer of no. The message now says that instead of naming a finding it does not have. The error itself already carries which case it was. Claude-Session: https://claude.ai/code/session_01JaxHfqFZk8GdrBNbtQZ6c2
…scan Found by mutating the loop rather than waiting to be told: replacing the `continue` with `return nil` passed the entire suite. A deploy runs its own gate jobs, so its own container is routinely the first one listed, and stopping there would step straight past a foreign container behind it — the exact overlap this check exists to catch, silently unguarded. The behaviour was already right; nothing held it there. Claude-Session: https://claude.ai/code/session_01JaxHfqFZk8GdrBNbtQZ6c2
There was a problem hiding this comment.
🟡 Changes recommended
Deploy refusal currently keeps the application lock without clearly informing the operator in the returned error (and one new comment is misleading), which can cause confusing operational behavior.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
internal/engine/deploy.go:123
- When
refuseForeignJobContainersreturns an error, the deploy keeps the application lock (by design) but the returned error doesn’t mention this, so an operator may not understand why subsequent mutating commands are blocked. Consider wrapping the error to explicitly state that the lock is being held and for how long (TTL).
if err := e.refuseForeignJobContainers(ctx, releaseID, epoch); err != nil {
holdLockForLiveContainer = true
return err
}
- Files reviewed: 7/7 changed files
- Comments generated: 2
- Review effort level: Lite
…fusal cases The deploy path kept the application lock on refusal and said nothing about it. An operator told only that the deploy stopped had no way to know the host was still held, or for how long. It now says so, in the same shape as the job path. Two comments corrected. The deploy one claimed the lock is kept when a live container is found — it is also kept when the host cannot be asked, which is the same inaccuracy fixed on the job path last commit and left here. The job one claimed the narration is silent about what was found, while the error it embeds may well name the container; what is true is that the sentence around it asserts nothing, and the finding travels in the error. Claude-Session: https://claude.ai/code/session_01JaxHfqFZk8GdrBNbtQZ6c2
There was a problem hiding this comment.
🟢 Approval recommended
The refusal logic is integrated at the stated call sites, fails closed on unusable Docker answers, and is covered by targeted unit and integration-style tests that pin behavior and lock handling.
Review details
- Files reviewed: 7/7 changed files
- Comments generated: 0 new
- Review effort level: Lite
Replaces #181, which I am closing. Refs #179 — does not close it.
Why this instead of #181
#181 did two things: refuse while a job container is live, and write the terminal record for runs whose client never did. Five review rounds found seven defects, every one of them in the second half — the epoch grouping, a result branch that could never fire, a reconciler that closed the job it was about to run, wrong operator attribution. A mutation test then showed the tests were largely inert: all four call sites could be deleted with the suite green.
So this is the first half only. One question, asked at two call sites, small enough to verify. The reconciliation work goes back to #179 as unstarted, to be built properly rather than repaired again.
What it does
A sealed job run executes attached over SSH. When the client goes away the container keeps running — the daemon owns it — while nothing on the workstation does. The lock is then either dropped outright (Ctrl-C releases it on its own background context) or expires on a dead heartbeat, and the next deploy or job run proceeds alongside a live data-changing container.
ob deployandob job runnow refuse while another operation's job container is running, naming it and how to inspect it.Two decisions worth reviewing
It asks Docker, not the journal. A journal records what a client managed to write, and the failure this exists for is a client that did not write. A run that recorded its own interruption looks finished on paper while its container keeps going; a re-run plan appends a second invocation to the same journal; a deploy-phase gate container never appears in a job-run journal at all. One
docker pssees all three. #181 learned this the expensive way.Matching is on operation AND epoch. A sealed plan carries one operation id for its whole life and is re-runnable, and
AcquireLock(lock.go:155) hands the lock straight back to a caller presenting the id already written in it. Matching the operation alone would let a second run of one plan reclaim the lock from a live first run and then exempt that run's container as its own — two concurrent migration containers, which is the single thing this prevents.ob resumereaches the same hole, since it carries the interrupted attempt's id. A container with no epoch cannot be shown to belong to this invocation and is not exempt from it.Placement follows the diagnostics: after the staleness checks so a stale plan is told it is stale rather than told about a container, and after
preflightso an unreachable daemon is reported by the check that exists for that — while still preceding every mutation.Known gaps, not fixed here
ob deployandob job runcheck. Twenty-one call sites take the application lock; two check.ob backup restoreandob destroyare the sharpest — restore's own contract says it must not interleave with a deploy or a recovery, and restoring a volume under a live migration container is exactly that.ob rollback,ob abort,ob service apply,ob exec,ob secrets push,ob schedule runand bootstrap are the rest. Tracked on Sealed manual job can complete after client disconnect but remain permanently INCOMPLETE #179.AcquireLockis not a safe one-liner: bootstrap takes the lock before Docker exists, androllback/secrets push/service applypass release ids as the deploy id, which would make the rollback case self-exempt.Test gaps, named rather than implied
The call sites are pinned, but their arguments are not: passing epoch
0, or the wrong operation id, survives the suite. Placement is not pinned either — moving the deploy check after the managed-proxy convergence stays green. And the probe command itself is unpinned, because every fake keys on a substring and returns hand-written output: swappingdocker psfordocker ps -asurvives, as does dropping the epoch from the format string. Worth closing, but each needs a fake that modelsdocker psrather than matching it.Verification
Six unit tests on the predicate — foreign operation, own container, an earlier invocation of the same operation, an empty operation label, a missing epoch — plus two integration tests that drive
ob deployandob job runagainst a host reporting a foreign container. Both call sites verified pinned: deleting either makes a test fail. That is the check #181 did not have.go test ./...— all packages pass.https://claude.ai/code/session_01JaxHfqFZk8GdrBNbtQZ6c2