Skip to content

Scope the in-flight reattach store to the dispatch unit, not the machine#433

Merged
ppXD merged 4 commits into
mainfrom
test/harden-flaky-e2e-timing
Jun 11, 2026
Merged

Scope the in-flight reattach store to the dispatch unit, not the machine#433
ppXD merged 4 commits into
mainfrom
test/harden-flaky-e2e-timing

Conversation

@ppXD

@ppXD ppXD commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • A parallel StartWithPrevious batch dispatches its steps concurrently (Task.WhenAll). When two steps share a target machine, both call DispatchOrReattachAsync for the same (task, machine) at once. The in-flight reattach store (InFlightScriptStore) was keyed by (serverTaskId, machineId) only, so the second dispatch's reattach probe found the first's just-recorded ticket and "skipped duplicate dispatch" — silently running the first step's script instead of its own, with the losing observer returning exit -1 and failing the deployment. Record-before-RPC (Record the in-flight ScriptTicket before the StartScript RPC #431) widened the window by persisting the ticket before StartScript.
  • Fix: key the in-flight slot by the dispatch unit via a new DispatchSlot(MachineId, StepId, ActionId). It keys on the stable, process-unique step/action ids — NOT display names, which carry no uniqueness constraint (the schema enforces unique step_order/action_order only, so two identically-named steps would otherwise re-collapse to one slot). The ids come from the frozen process snapshot, so they are identical across a crash→resume; threaded onto ScriptExecutionRequest and attached post-render at the single dispatch site (alongside the existing masker attach).
  • Concurrent dispatches to one machine now stay independent — live and on resume each re-attaches only to its OWN ticket. The checkpoint JSON moves from a machine-keyed object to a {m,s,a,t} list; older/interim shapes deserialise to empty (the documented re-dispatch fail-safe), so it is non-breaking.
  • This is the real bug behind the flaky StepBatcherParallelE2ETests (two StartWithPrevious steps on one agent): with the slot fix both steps run their own script, the deployment succeeds, and the agent-side execution intervals overlap deterministically. The E2E also now proves overlap from agent-clock timestamps emitted via a date format string (immune to the JSON action-property round-trip) instead of a flaky total-wall-clock ceiling.

Test plan

  • Unit (5986/5986): InFlightScriptMapTests — same-machine/different-action-id independence, sibling-slot non-match, legacy machine-keyed + interim name-keyed shapes tolerated as empty
  • Integration (real Postgres): InFlightScriptStoreTests dispatch-scoped independence + concurrent same-machine record; HalibutResumeReattachTests.ConcurrentDispatch_SameMachineIdenticallyNamedSteps_… — deterministic two-concurrent-dispatch regression with TWO identically-named steps differing only by id (a gate parks step A's RPC until step B has dispatched), proving no cross-reattach, StartScriptCalls == 2
  • E2E (K8s Pipeline): StepBatcherParallelE2ETests two StartWithPrevious steps on one agent → Success + overlapping intervals
  • Full unit suite green; non-breaking (transient checkpoint column, fail-safe handles old shapes)
  • Adversarial multi-agent review (concurrency / resume / non-breaking / completeness) — the name-vs-id finding drove the id-keying

StepBatcherParallelE2ETests proved StartWithPrevious parallelism by
stop-watching the whole ProcessAsync and asserting < 3.5s. That measured
pipeline overhead (Halibut handshake, 1s polling, DB, Kind latency) as
much as the step overlap, so a genuinely-parallel run could breach the
ceiling on a loaded CI runner — it false-failed 4+ times across unrelated
PRs, including a unit-test-only change. Replace it with an agent-side
interval-overlap proof: each step echoes an epoch timestamp (date +%s.%N)
around its sleep, and the test asserts the two execution intervals
overlap (min(end) - max(start) > 1s). This measures only the overlap and
is immune to pipeline/CI overhead.

HalibutCircuitBreakerE2ETests used the same stopwatch-around-pipeline
pattern (< 5s). Its real proof is non-temporal (INVARIANT 3: the stub
never ran the script); widen the wall-clock to a generous 20s anti-hang
guard (the regression it catches is a full 30-min script timeout) so
DB-bound prepare phases can't breach it under CI load.
@ppXD ppXD added this to the 1.9.0 milestone Jun 11, 2026
ppXD added 2 commits June 11, 2026 13:40
…itution

The interval-overlap rewrite emitted each step's epoch with
  echo "STEPTIME <id> START $(date +%s.%N)"
whose double quotes + command substitution did not survive the JSON
action-property round-trip into the agent — the body failed to parse and
the script exited 1 before the sleep, so the deployment never reached
Success.

Bake the marker into date's own format string instead:
  date +STEPTIME_<id>_START_%s.%N
This emits the same STEPTIME line with no shell quoting and no command
substitution, so it round-trips verbatim. Parser regex updated to the
underscore-delimited form.
A parallel StartWithPrevious batch dispatches its steps concurrently via
Task.WhenAll. When two steps in the batch share a target machine, both
call DispatchOrReattachAsync for the same (task, machine) at once. The
in-flight reattach store was keyed by (serverTaskId, machineId) only, so
the second dispatch's reattach probe found the first's just-recorded
ticket and "skipped duplicate dispatch" — silently running the first
step's script instead of its own, with the losing observer returning
exit -1 and failing the deployment. Record-before-RPC (P1a) widened the
collision window by persisting the ticket before StartScript.

Key the in-flight slot by the dispatch unit (machine, step, action)
instead. Concurrent dispatches to one machine now stay independent both
live and on resume — each re-attaches only to its OWN ticket. The
checkpoint JSON moves from a machine-keyed object to a list of
{m,s,a,t} entries; an older machine-keyed row deserialises to empty
(the documented re-dispatch fail-safe), so it is non-breaking.

This is the real bug behind the flaky StepBatcherParallel E2E (two
StartWithPrevious steps on one agent): with the slot fix both steps run
their own script, so the deployment succeeds and the execution
intervals overlap deterministically.

Tests:
- Unit: InFlightScriptMap same-machine/different-step independence +
  legacy-shape tolerance
- Integration: InFlightScriptStore dispatch-scoped independence +
  concurrent same-machine record; HalibutResumeReattach deterministic
  two-concurrent-dispatch regression (gate parks step A's RPC until
  step B has dispatched) proving no cross-reattach
@ppXD ppXD changed the title Harden flaky wall-clock E2E timing assertions Scope the in-flight reattach store to the dispatch unit, not the machine Jun 11, 2026
Adversarial review found the prior slot keyed by (machineId, StepName,
ActionName) still re-opened the collision for duplicate names: step and
action names have NO uniqueness constraint (the schema only enforces
unique step_order / action_order), so two distinct identically-named
steps targeting one machine in a parallel batch collapse to the same
slot and re-attach to each other — the exact bug, merely narrowed.

Key DispatchSlot by the stable, process-unique (MachineId, StepId,
ActionId) instead. The ids come from the frozen process snapshot, so
they are identical across a crash→resume. Threaded onto
ScriptExecutionRequest and attached post-render in ExecuteStepsPhase
(single injection point, alongside the existing Masker attach — the
renderer carries only display names). The checkpoint JSON now stores
{m,s,a,t} with integer s/a; the interim name-keyed list and the older
machine-keyed object both deserialise to empty (re-dispatch fail-safe).

The concurrent-dispatch regression now uses TWO identically-named steps
('deploy'/'deploy') differing only by id, proving the slot keys on ids
not names — it would re-collide under the name key.
@ppXD
ppXD merged commit c06fc25 into main Jun 11, 2026
15 checks passed
@ppXD
ppXD deleted the test/harden-flaky-e2e-timing branch June 11, 2026 06:46
@ppXD ppXD added this to the 1.9.0 milestone Jun 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant