Files
zeekay 2bf2702045 feat(tasks): event-sourced durable activity core (Phase-2a)
Replace the coroutine-push activity path with deterministic, history-backed
replay. One way: the old push-result mechanism is removed.

Server (pkg/tasks):
- durable.go: seq-keyed activity records (wfact/ns/wf/run/seq) — the
  exactly-once anchor. applyScheduleActivity is idempotent per (run,seq);
  completeWorkflowActivity records the terminal outcome and schedules a new
  workflow task so the worker replays and advances. Per-run keyedMutex
  serializes advancing a run. Server-side retry+backoff per RetryPolicy
  (temporal.RetryPolicy, finite default bound). Recover() rebuilds in-flight
  dispatch from the store at boot (called from Embed).
- workflow tasks now carry the run's full event-sourced history.
- dispatch.go: ScheduleActivity/CompleteActivityTask(push) -> DispatchActivity
  (seq-bound token) + ResolveActivityToken. workflowPeer/pendingActivity and
  OpcodeDeliverActivityResult retired.
- respondWorkflowHandler applies ScheduleActivity{seq} commands under the run
  lock; respondActivity* resolve the token and advance the run.

Worker (pkg/sdk/worker):
- workerEnv is now a replay decider: ExecuteActivity resolves from history by
  seq (recorded result -> no re-dispatch; else emit ScheduleActivity{seq} and
  end the episode via a blockedFuture). Stateless between episodes.
- removed OnActivityResult/pendingActivities push machinery.

Client (pkg/sdk/client): drop OnActivityResult + ScheduleActivity from
WorkerTransport (dead in the replay model).

Tests: e2e (exactly-once, real workers) green; new worker replay determinism
tests (unresolved->schedule+block, resolved->recorded result, 2-activity
determinism). Full 'go test -race ./pkg/tasks/... ./pkg/sdk/...' green.
2026-07-02 18:15:37 -07:00

113 lines
2.8 KiB
Go

// Copyright © 2026 Hanzo AI. MIT License.
package worker
import (
"context"
"github.com/hanzoai/tasks/pkg/sdk/client"
)
// startSubscriptions wires the worker's server-push handlers and
// subscribes once per kind. Called from Start. The transport pushes
// task deliveries asynchronously after Subscribe returns; each
// delivery dispatches in a fresh goroutine bounded by the worker's
// existing semaphores so concurrency caps still apply.
func (w *workerImpl) startSubscriptions() error {
ctx, cancel := w.ctxWithStop(context.Background())
defer cancel()
w.transport.OnWorkflowTask(func(task *client.WorkflowTask) {
w.dispatchPushedWorkflowTask(task)
})
w.transport.OnActivityTask(func(task *client.ActivityTask) {
w.dispatchPushedActivityTask(task)
})
wfReq := client.PollWorkflowTaskRequest{
Namespace: w.namespace,
TaskQueueName: w.taskQueue,
TaskQueueKind: 0,
Identity: w.identity,
}
wfSub, err := w.transport.SubscribeWorkflowTasks(ctx, wfReq)
if err != nil {
return err
}
w.subMu.Lock()
w.workflowSubID = wfSub
w.subMu.Unlock()
actReq := client.PollActivityTaskRequest{
Namespace: w.namespace,
TaskQueueName: w.taskQueue,
TaskQueueKind: 0,
Identity: w.identity,
}
actSub, err := w.transport.SubscribeActivityTasks(ctx, actReq)
if err != nil {
return err
}
w.subMu.Lock()
w.activitySubID = actSub
w.subMu.Unlock()
return nil
}
// dispatchPushedWorkflowTask runs in the transport's delivery goroutine
// and hands the task off to a worker-owned goroutine bounded by the
// workflowExecSem cap so back-pressure still applies. The transport
// goroutine returns immediately so it can ack the push and accept the
// next delivery.
func (w *workerImpl) dispatchPushedWorkflowTask(task *client.WorkflowTask) {
if w.stopped() {
return
}
w.wg.Add(1)
go func() {
defer w.wg.Done()
ctx, cancel := w.ctxWithStop(context.Background())
defer cancel()
if w.workflowExecSem != nil {
select {
case w.workflowExecSem <- struct{}{}:
case <-ctx.Done():
return
case <-w.stopCh:
return
}
defer func() { <-w.workflowExecSem }()
}
w.dispatchWorkflowTask(ctx, task)
}()
}
// dispatchPushedActivityTask is the activity counterpart. Rate limits
// are enforced before dispatch via activityLimiter and taskQueueLimiter,
// matching the contract the previous poll loop honoured.
func (w *workerImpl) dispatchPushedActivityTask(task *client.ActivityTask) {
if w.stopped() {
return
}
w.wg.Add(1)
go func() {
defer w.wg.Done()
ctx, cancel := w.ctxWithStop(context.Background())
defer cancel()
if w.activityLimiter != nil {
if err := w.activityLimiter.Wait(ctx); err != nil {
return
}
}
if w.taskQueueLimiter != nil {
if err := w.taskQueueLimiter.Wait(ctx); err != nil {
return
}
}
w.dispatchActivityTask(ctx, task)
}()
}