Skip to content

Update suspend and pause tests to use SuspendActor and PauseActor APIs - #2688

Merged
Dmitry Berkovich (dberkov) merged 4 commits into
agent-substrate:mainfrom
zoez7:merge-suspend-pause
Oct 15, 2026
Merged

Dmitry Berkovich (dberkov) merged 4 commits into
agent-substrate:mainfrom
zoez7:merge-suspend-pause

Conversation

@zoez7

@zoez7 Zoe Zhao (zoez7) commented Oct 14, 2026 •

Copy link
Copy Markdown
Collaborator

This is a step toward async suspend. The next PR merges SuspendActor and PauseActor into one set of shared steps: sync suspend, and async suspend (today's pause). Both APIs stay.

The current unit tests call the private step methods (ensureMarked*, ensureAtelet*, ensure*Finalized). But the next PR will rewrite exactly those steps. If the tests stay as they are, they get rewritten in the same PR as the refactor, and nothing shows that the refactor kept the behavior.

This PR moves the tests onto the public SuspendActor and PauseActor entrypoints and checks them against the current implementation. The merge PR then shouldn't need to touch any test file: if this suite still passes unchanged, behavior is preserved.

Test-only change.

  • Tests pass
  • Appropriate changes to documentation are included in the PR


ctx := context.Background()
persistence := newTestPersistence(t)
seedSuspendTemplate(t, ctx, persistence, &ateapipb.SnapshotConfig{StorageLocation: testStorageLocation, PreferredFidelity: volumes})

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 should-fix 🟡 – The matrix never seeds the worker that wireTestAssignment() names. As a result, every row that succeeds goes through finalize's "worker already gone, skipping release" branch, and the normal ReleaseActorFromWorker path is not pinned here. A refactor that stopped releasing the worker would still pass this matrix, even though this is the suite the next PR depends on to prove it kept the behavior.

Give each row its own store, call seedWireWorker(t, ctx, st, assignmentOf(actor)), and add whether the worker's assignment was released to suspendOutcome. That way, rejected rows also show the worker was left alone.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7aef594. Each sync/async run now gets its own store and calls seedWireWorker. The worker holds the actor's assignment when the row has a WorkerAssignment, and is empty otherwise. suspendOutcome gained workerHeld, read from the worker's assignments after the run. Every successful row pins false (released). The three rejected rows that start with an assignment (suspend in progress/async, pause in progress/sync, golden/async) pin true, so the worker is shown untouched.

While here, the matrix now compares the atelet RPC and the stored snapshots as typed structs (ateletCall, snapshotOutcome) instead of formatted strings.

}
if want := ateattr.SnapshotFidelityValue(tc.want); wireFidelity != want {
t.Errorf("wireFidelity = %q, want %q", wireFidelity, want)
if _, err := w.PauseActor(ctx, actorRef); err != nil {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 should-fix 🟡 – The old test checked the wireFidelity that ensureAteletPaused returned. That value becomes the snapshot.fidelity label on the pause and suspend lifecycle-op metric. Without that check, no test covers this mapping through PauseActor or SuspendActor, so the refactor could drop or change the label without a test failing.

Set w.instruments from newTestInstruments(t) and assert the attribute on the recorded data point. Do the same in the suspend wire-request and promote tests.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7aef594. Added assertLifecycleFidelity, which reads the lifecycle-op datapoint's snapshot.fidelity attribute. These tests now set w.instruments from newTestInstruments(t) and assert it:

  • TestPauseActor_AteletPaused_ForwardsPreferredFidelity (each fidelity row)
  • TestSuspendActor_AteletSuspended_WireRequest
  • TestSuspendActor_Finalized_NoAssignment, the test that drives the PromoteSnapshot path

func TestSuspendAndPause_FinalizeWithoutAssignment(t *testing.T) {
wantState := map[string]ateapipb.ActorState{
"sync": ateapipb.ActorState_ACTOR_STATE_SUSPENDED,
"async": ateapipb.ActorState_ACTOR_STATE_SUSPENDING,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 question 🟢 – In the async row, PauseActor reports success but leaves the actor SUSPENDING with no worker assignment, and nothing appears to move it on from there. Is that intended, or a second bug next to #2676? Either way, the comment should say which side is the known bug. Then whoever fixes it knows which pin to change, rather than reading both as the intended behavior.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. That shape doesn't happen in practice: no writer clears the assignment and leaves the actor SUSPENDING. The real writer is the DeleteWorker workflow, which sets CRASHED and clears the assignment in one update. In 7aef594 the test's mid-Checkpoint mutation does the same. Now the async side keeps CRASHED, which is the intended behavior (pause finalize doesn't overwrite an out-of-band transition). The sync side commits SUSPENDED over the crash, which is the #2676 bug. The test comment says so, and says the fix should change the sync pin to CRASHED.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Following up: the pause side now ends CRASHED but returns OK, while TestPauseActor_Finalized_AlreadyCrashed pins FailedPrecondition for a crash mid-pause. Is OK on a crashed actor intended? If not, it may belong next to #2676 as a known bug rather than a pin the merge must keep.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I updated 2676 to include this bug as well. I added the fix to #2694

w, atelet := newWireCaptureWorkflow(t, persistence)

_, err := mode.run(w, ctx, actorRef)
if got := apierror.Code(err); got != want.code {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 nit 🟢 – code is checked here and again in the cmp.Diff below, so a mismatch is reported twice. The cmp.Diff alone is enough.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7aef594. Removed the separate code check. The cmp.Diff failure message now includes err, so the error detail is still reported.

Comment thread cmd/ateapi/internal/controlapi/workflow_suspend_test.go Outdated
Comment thread cmd/ateapi/internal/controlapi/workflow_suspend_test.go Outdated
Comment thread cmd/ateapi/internal/controlapi/workflow_suspend_test.go Outdated
Comment thread cmd/ateapi/internal/controlapi/workflow_suspend_test.go
…Actor

The step-level tests called the private steps of both workflows directly
(ensureMarkedPausing, ensureAteletPaused, ensurePausedFinalized,
ensureMarkedSuspending, ensureAteletSuspended, ensureSuspendedFinalized).
Drive them through the public SuspendActor and PauseActor entrypoints
instead, so the two workflows can share their steps without touching a
test file.

- A dangling atelet dialer stops a workflow right after its mark step,
  for tests that pin what the mark commits.
- The capturing atelet fake now records PromoteSnapshot, counts calls,
  and can mutate the store mid-Checkpoint, for tests that pin a race
  between the atelet call and finalize.
- TestSuspendAndPause_CharacteristicMatrix pins, for each actor shape,
  the code, atelet RPC, and stored record of both a sync (SuspendActor)
  and an async (PauseActor) suspend.
- TestSuspendAndPause_FinalizeWithoutAssignment pins the finalize
  divergence tracked in agent-substrate#2676.

Test-only change.
- TestSuspendAndPause_CharacteristicMatrix gives each run its own store,
  seeds the worker wireTestAssignment names, and pins whether the worker
  still holds the actor's assignment, so a dropped release is caught.
- The matrix compares the atelet RPC and the stored snapshots as typed
  structs (ateletCall, snapshotOutcome) instead of formatted strings.
- The pause wire test, the suspend wire-request test, and the promote
  test assert the snapshot fidelity label on the lifecycle-op metric.
- TestSuspendAndPause_FinalizeWithoutAssignment now crashes the actor as
  the DeleteWorker workflow does, and names the suspend side as the
  agent-substrate#2676 bug.
- Drop the duplicate code check in the matrix.

Test-only change.
…pins

Key the suspendOutcome literals in the characteristic matrix so rows
can be checked against the test doc, mark both sides of
TestSuspendAndPause_FinalizeWithoutAssignment as agent-substrate#2676 bugs, and use
newMarkOnlyWorkflow in the paused-snapshot precondition tests.
…flows

TestHostingNodeAfterWorkerRelease called ensurePausedFinalized and
ensureSuspendedFinalized directly, the last private step calls in the
package. Run those rows through PauseActor and SuspendActor against the
wire capture workflow instead, seeding the worker wireTestAssignment
names and the suspend test template so the atelet call reaches the fake.
@dberkov
Dmitry Berkovich (dberkov) added this pull request to the merge queue Oct 15, 2026
Merged via the queue into agent-substrate:main with commit 4c3e623 Oct 15, 2026
15 checks passed
Sairaj Pokale (sairajp-rewind) pushed a commit to sairajp-rewind/substrate that referenced this pull request Oct 16, 2026
agent-substrate#2707)

Fixes agent-substrate#2676.

If worker deletion or another crash writer marks an actor CRASHED after
checkpoint succeeds but before finalize re-reads it, SuspendActor
currently overwrites that crash with SUSPENDED, while PauseActor reports
success for the CRASHED actor.

Both finalize steps now require the re-read actor to be SUSPENDING
before validating snapshots, releasing the worker, or writing the final
state. Otherwise they return FailedPrecondition and preserve the
existing state, crash reason, and snapshot records. The existing version
precondition still rejects a concurrent crash after the read with
Aborted. Completed suspend and pause calls retain their existing success
paths at workflow entry.

The full-workflow regression now checks FailedPrecondition and
preservation of the original crash in both modes, updating the behavior
pinned by agent-substrate#2688. The already-crashed pause fixture also clears
WorkerAssignment, matching a real crash; it previously reached
FailedPrecondition only through the missing-node branch.

Validation of the production change on Linux with CI=true and real
PostgreSQL test containers:

- RED: before the production fix, the corrected already-crashed pause
test and both modes of the full-workflow regression fail because the
calls incorrectly return OK.
- GREEN: `go test -race -count=1 -json ./...` passes: 158 test-bearing
packages and 9,670 test entries, including all 763 controlapi entries
and 169 API functional entries.
- `go build ./cmd/ateapi ./cmd/atelet ./cmd/kubectl-ate`,
`hack/verify-all.sh`, and the tools/apitool race tests pass. The
verifier leaves the source tree unchanged.
- The full suite skips 41 tests requiring root privileges, IPv6
loopback, or configured external object storage. The affected PostgreSQL
regressions do not skip.

E2E follow-up:

The original CI passed both gVisor suites but failed both microVM suites
in `TestGracefulWorkerTerminationSuspend`. That test unconditionally
expected SuspendActor to succeed after worker deletion, even when
deletion had already crashed the actor.

The test now ties the RPC result to the stored state. OK requires
SUSPENDED with no crash record. FailedPrecondition or Aborted requires
CRASHED, a crash reason, and the previous completed durable snapshot.
Both outcomes require the worker and its assignment to be removed. All
other errors fail the test.

Follow-up validation on Linux with CI=true and real PostgreSQL and
envtest:

- The four affected controlapi regressions pass with `-race -count=3`.
- All 763 controlapi test entries and all 169 functionaltest entries
pass, with no skips. The functional package was rerun after initializing
the isolated source directory's Git metadata, which envtest setup
requires.
- `go test -race -c ./internal/e2e/suites/demo` and `go vet
./internal/e2e/suites/demo` pass. Native compilation, gofmt, and
whitespace checks also pass.
- Real runtime E2E has not run at this head. The new [pr-workflow
run](https://github.com/agent-substrate/substrate/actions/runs/38817919692)
is `action_required` and needs maintainer approval.

Draft agent-substrate#2694 refactors these same workflows and currently preserves an
out-of-band state while returning success. This focused fix also reports
FailedPrecondition, as required by agent-substrate#2676; that behavior should carry
forward into the refactor.

- [x] Local validation passes; runtime E2E awaits workflow approval.
- [x] Documentation reviewed; finalize and regression-test comments
describe the corrected behavior. No guide change is needed.
Tim Hockin (thockin) pushed a commit to thockin/substrate that referenced this pull request Oct 16, 2026
)

This is the next step toward [async
suspend](https://github.com/zoez7/substrate/blob/async-suspend-design/docs/dev/async-suspend-en.md),
following agent-substrate#2688. `SuspendActor` and `PauseActor` now run one set of
shared steps behind a `suspendMode`:

- `suspendSync` (`SuspendActor`): the snapshot is durable before the
workflow returns.
- `suspendAsync` (`PauseActor`): the snapshot stays on the worker's
node.

Both APIs stay for now. `workflow_pause.go` is removed.

Behavior is unchanged. The tests agent-substrate#2688 moved testing of ensurePaused*
and ensureSuspended* functions onto `PauseActor` and `SuspendActor`. The
only test edit is in `workflow_test.go`, which called the removed
`ensurePausedFinalized`.


The fix for agent-substrate#2676 (finalize not checking that the actor is still
`SUSPENDING`) will come in a separate PR.

- [x] Tests pass
- [ ] Appropriate changes to documentation are included in the PR
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.

4 participants