Commit 4368411
fix(service-automation): a conditional advance claim on SuspendedRunStore, so two replicas cannot both advance one run (#14712)
* fix(service-automation): conditional advance claim on SuspendedRunStore (#14333)
Two concurrent resumes of one run on two replicas could both advance it: the
idempotency guard was `this.resuming`, an in-process Set, so each replica
passed its own check, both read the same fresh row from the shared store, both
consumed it and both traversed forward — every downstream side effect twice.
Measured first, on the two-engines-over-one-shared-store harness: 25/25 raced
runs advanced twice, for both shapes the report named (parallel / any-of
approvers and duplicated automated approve calls); a single approver per level
deciding sequentially does not race and is pinned as the negative control.
`SuspendedRunStore` gains an optional `claimSuspension(runId, parkedAt)` — the
compare-and-set put where the shared state lives: consume the row only if it is
still parked at the node the caller read. The winner advances, the loser is
refused RESUME_IN_PROGRESS and runs nothing, and `this.resuming` stays the
cheap first gate. Both shipped stores implement it; `ObjectStoreSuspendedRunStore`
uses the data engine's documented compare-and-set route with the columns
`sys_automation_run` already carries, so no platform-object schema changes.
A store that cannot express the condition is announced once at `warn`, never
silently offered no guarantee.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
* test(service-automation): record the measured ablation ledger (#14333)
The REVERT-PROOF block carried a predicted 5 red / 6 green. Measured on the
committed tree, with the mutation confirmed on disk by anchored counts and the
blob hash, it is 4 red / 4 green — with the failing values named case by case,
so the ledger describes the file that exists.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
* docs(permissions): re-certify the tenant-audit census at 218 write call sites (#14333)
`ObjectStoreSuspendedRunStore.claimSuspension` adds exactly one write call
site on the application surface — the conditional `delete` that decides the
cross-replica advance — so the shrink-only census the gate holds the tree to
moves 217 -> 218. Regenerated with `node scripts/tenant-audit-census.mjs
--write`, and the nine hand-written prose figures the generator does not own
updated by hand to match.
Measured pre-existing state, so the delta is attributable: with these three
sources reverted to the merge base and the census restored to HEAD, the gate
is green at 217 — this PR moves it by exactly one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
* test(service-automation): pin the compare-and-set condition and the production store (#14333)
Patch round 1 for the isolated contract review's two BLOCKING findings. Both
were the same defect in two places: the guard was real but no test could tell
it from a guard that is not there.
BLOCKING 1 — the condition was unpinned. Every earlier race let the loser lose
by finding NO row, which an existence-only consume satisfies too. Two new tests
hold the loser's claim until after the winner has advanced and RE-PARKED, one
per comparison: a re-park at a different node, and a `map` re-entry at the same
node with a new correlation. Both assert the parking the loser read (so the
precondition is measured, not assumed) and that the winner's live suspension
SURVIVES — the half a doubled effect alone would not catch, since an
existence-only consume strands the run by deleting the parking another replica
is standing on.
BLOCKING 2 — `ObjectStoreSuspendedRunStore.claimSuspension` had no test at all.
`createFakeEngine` now dispatches through the producer's own predicate
(`assertEngineDeleteDispatch`), so the double cannot accept a call
`ObjectQL.delete` refuses, and a new suite pins the predicate spelling (read
back through that same predicate, never by matching the literal token), the
count-to-outcome mapping, `'lost'` from a moved node and from a moved
correlation, both `'unsupported'` branches with their once-per-store line, two
engines racing over ONE durable store, and a throwing claim mapping to
STORE_UNAVAILABLE. Also pins the loser's `debug` line.
§5 notes 2/3/4, all of them this PR's own new text:
- the STORE_UNAVAILABLE message no longer asserts "the suspension was NOT
consumed" — a throw can arrive after a committed delete, so it now states
only that this resume did not continue the run and hands the ambiguity to a
retry;
- the store docblock names the THIRD exposure: a driver with no `deleteMany`
makes ObjectQL.delete throw on the predicate path, so such a composition gets
STORE_UNAVAILABLE on every resume rather than `'unsupported'`;
- the store-level `'unsupported'` warn is deduped to once per store instance,
keeping the once-per-engine promise the PR body makes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
* test(service-automation): record the three measured ablation ledgers (#14333)
The pin file's REVERT-PROOF block described a two-file, eight-test population
that no longer exists. Re-measured on the committed tree, mutation proven on
disk and restore proven byte-identical for each:
(E) engine stops asking 9 failed | 52 passed (61)
(C) condition stops being one 2 failed | 59 passed (61)
(C2) store loses its predicate 7 failed | 54 passed (61)
(C) and (C2) are the review's own ablations, both measured GREEN across the
whole branch before this round.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
* chore(gates): record the newly pinned engine double and close its DEBT row (#14333)
`createFakeEngine` in `suspended-run-store.test.ts` now dispatches through
`assertEngineDeleteDispatch`, which is exactly what that file's DEBT entry named
as its close condition ("replace the mirrored `if` with
assertEngineDeleteDispatch(options) — the devDependency is already declared").
Both halves, as `check:engine-double-contract` prescribes: the pinned ledger
learns about the new coverage (`--write`, one row added, none lost) and the
closed DEBT row is deleted in the PR that fixed it. The ratchet moves in the
shrinking direction — 754 pinned / 134 DEBT becomes 755 pinned / 133 DEBT.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent a7a7390 commit 4368411
10 files changed
Lines changed: 1332 additions & 41 deletions
File tree
- .changeset
- content/docs/permissions
- docs/audits
- packages/services/service-automation/src
- scripts
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
98 | 98 | | |
99 | 99 | | |
100 | 100 | | |
101 | | - | |
| 101 | + | |
102 | 102 | | |
103 | 103 | | |
104 | 104 | | |
| |||
147 | 147 | | |
148 | 148 | | |
149 | 149 | | |
150 | | - | |
| 150 | + | |
151 | 151 | | |
152 | | - | |
153 | | - | |
| 152 | + | |
| 153 | + | |
154 | 154 | | |
155 | 155 | | |
156 | 156 | | |
| |||
161 | 161 | | |
162 | 162 | | |
163 | 163 | | |
164 | | - | |
| 164 | + | |
165 | 165 | | |
166 | 166 | | |
167 | 167 | | |
168 | 168 | | |
169 | 169 | | |
170 | | - | |
| 170 | + | |
171 | 171 | | |
172 | 172 | | |
173 | 173 | | |
174 | | - | |
| 174 | + | |
175 | 175 | | |
176 | 176 | | |
177 | 177 | | |
| |||
183 | 183 | | |
184 | 184 | | |
185 | 185 | | |
186 | | - | |
187 | | - | |
| 186 | + | |
| 187 | + | |
188 | 188 | | |
189 | | - | |
| 189 | + | |
190 | 190 | | |
191 | | - | |
| 191 | + | |
192 | 192 | | |
193 | 193 | | |
194 | 194 | | |
195 | 195 | | |
196 | | - | |
| 196 | + | |
197 | 197 | | |
198 | 198 | | |
199 | 199 | | |
200 | 200 | | |
201 | 201 | | |
202 | | - | |
| 202 | + | |
203 | 203 | | |
204 | 204 | | |
205 | 205 | | |
206 | 206 | | |
207 | 207 | | |
208 | | - | |
| 208 | + | |
209 | 209 | | |
210 | 210 | | |
211 | 211 | | |
| |||
224 | 224 | | |
225 | 225 | | |
226 | 226 | | |
227 | | - | |
| 227 | + | |
228 | 228 | | |
229 | 229 | | |
230 | 230 | | |
231 | | - | |
| 231 | + | |
232 | 232 | | |
233 | 233 | | |
234 | | - | |
| 234 | + | |
235 | 235 | | |
236 | 236 | | |
Lines changed: 9 additions & 9 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
29 | 29 | | |
30 | 30 | | |
31 | 31 | | |
32 | | - | |
33 | | - | |
| 32 | + | |
| 33 | + | |
34 | 34 | | |
35 | | - | |
| 35 | + | |
36 | 36 | | |
37 | | - | |
| 37 | + | |
38 | 38 | | |
39 | 39 | | |
40 | 40 | | |
41 | 41 | | |
42 | | - | |
| 42 | + | |
43 | 43 | | |
44 | 44 | | |
45 | 45 | | |
| |||
52 | 52 | | |
53 | 53 | | |
54 | 54 | | |
55 | | - | |
| 55 | + | |
56 | 56 | | |
57 | 57 | | |
58 | 58 | | |
59 | | - | |
| 59 | + | |
60 | 60 | | |
61 | 61 | | |
62 | | - | |
| 62 | + | |
63 | 63 | | |
64 | 64 | | |
65 | 65 | | |
| |||
158 | 158 | | |
159 | 159 | | |
160 | 160 | | |
161 | | - | |
| 161 | + | |
162 | 162 | | |
163 | 163 | | |
164 | 164 | | |
| |||
0 commit comments