SRD-036 — Service Task external-worker job queue & wait-node (M2, M3)¶
| Field | Value |
|---|---|
| Status | Accepted |
| Version | v.1 |
| Date | 2026-07-06 |
| Owner | Ruslan Gabitov |
| Implements | ADR-021 v.1 Service Task Execution Model §2.1–§2.5 |
Accepted — second of four SRDs landing ADR-021 v.1 (M2 + M3 of M1–M8). Lands the external-worker foundation: M2 redefines the reserved
WorkerDispatcherseam from a blocking dispatch into an asynchronous fetch-and-lock job queue and reworks the in-memorylocaldispatcherinto a job store; M3 makes aWithWorker(topic)ServiceTask a wait node that parks on activation, enqueues its bound input, and resumes when a worker reports. Outcomes here are success (Complete) or technical fault (Fail); classification (ErrorMapper/WithStatus/BpmnError), output mapping, retry, and theWithWorkerTrustprotocols land in SRD-037 (M4/M5) and SRD-038 (M6–M8). Sibling: SRD-035 (M1,WithTimeout, Accepted).
1. Background (verified against the code)¶
1.1 The decision this SRD lands (ADR-021 v.1 §2.1–§2.5)¶
A worker-dispatched ServiceTask is a wait node: on activation it parks, the engine enqueues a job onto an asynchronous fetch-and-lock queue, a worker pulls it, executes, and reports; the report re-enters the instance loop and resumes the parked track. The engine holds no live call — only a queued job and a parked track — so a worker-waiting instance is dehydration-ready.
1.2 The seam being reworked (verified)¶
The reserved WorkerDispatcher is a blocking dispatch with no consumer:
// pkg/tasks/workerdispatcher.go:13-32
type Job struct { Input any; Type string; ID string }
type Handler func(ctx context.Context, job Job) (any, error)
type WorkerDispatcher interface {
Register(jobType string, h Handler) error
Dispatch(ctx context.Context, job Job) (any, error) // REWORKED away
}
The in-memory impl is a semaphore-backed goroutine pool (pkg/tasks/localdispatcher/localdispatcher.go:31-93:
Dispatcher{handlers, sem, mu}, New(poolSize), Register, Dispatch). Nothing in the ServiceTask/track path
calls it today (ADR-021 §1 problem 2) — so the rework has no in-tree consumer to break.
1.3 The rails M3 rides — the wait-node template (verified, REUSE)¶
M3 mirrors the UserTask wait-node path exactly (ADR-020 v.1, SRD-034):
checkNodeType(internal/instance/track.go:345-351) diverts wait nodes before the EventNode path:if _, ok := node.(interactor.HumanTask); ok { return t.parkHumanTask(node) }. M3 adds a ServiceTask branch.parkHumanTask(track.go:427-441):t.updateState(TrackWaitForEvent)(track.go:71, the shared wait-node state) +t.instance.emit(trackEvent{kind: evTaskWaiting, track: t, node, taskID}). M3 adds an analogousparkExternalServiceTaskwith a newtrackEventKind.- Resume: the loop delivers to
t.evtCh;delivercalls the node'sProcessEvent(track.go:1005-1031,ep.ProcessEventat:1029).UserTask.ProcessEvent(user_task.go:215-235) type-asserts a synthetic*interactor.TaskCompletion, stashescompletedOutputs, andExecbinds them on resume. M3'sServiceTask.ProcessEventstashes aWorkerOutcomethe same way. - Single-writer delivery:
Instance.events chan trackEvent(instance.go:90, loop-only sender) +evDeliver(event.go:98-104);Instance.ProcessEvent(instance.go:355-368) shows theinst.emit(evDeliver…)pattern a report re-enters through.
1.4 The operation contract M3 reuses (verified)¶
op.Execute(ctx, re) binds a single input message item and produces a single output item
(pkg/model/service/operation.go BindInput:252-284, produceOutput:219-246). A goOperation is an in-process
Go closure with Type() == gooper.GoOperType ("##GoOper", gooper.go:22) — not shippable. Job.Input /
Complete output are single *data.ItemDefinitions per the operation contract (multi-variable assembly is a
data-layer follow-up, AB-005).
2. Requirements¶
Functional — M2 (job queue)¶
- FR-1 — Redefine
pkg/tasks.WorkerDispatcherinto the async job-queue interface (§3.1):Enqueue/FetchAndLock/ExtendLock/Complete/Fail, with named ids (JobID/Topic/WorkerID) andJob{ID, Topic, Input *data.ItemDefinition, Policy *Policy},LockedJob{Job, WorkerID, Deadline}.Policyis a nil placeholder here (populated by SRD-038). The oldRegister/Dispatch/HandlerandJob.Input anyare removed. - FR-2 — Rework
localdispatcherinto an in-memory job store: topic-keyed queue; per-job lock state (holderworkerID,Deadline);FetchAndLockhands out unlocked jobs for the requested topics and locks them forlockDuration; a lock that expires without a report makes the job fetchable again (crash-resilience);ExtendLockis holder-only and bounded by a configurablemaxLockDurationcap; a local worker pool fetches-and-locks and runs registered per-topic handlers. - FR-3 — The reworked
localdispatcherstays the wired default (enginert/thresherdefaultConfig,renv.EngineRuntime.WorkerDispatcher());thresher.WithWorkerDispatcherstill injects an alternative.
Functional — M3 (wait-node)¶
- FR-4 —
activities.WithWorker(topic)selects the external-worker locus (a newSrvTaskOptionon the M1srvTaskConfig). On activation the ServiceTask parks —checkNodeTypegains a branch that, for a worker-dispatched ServiceTask, callsparkExternalServiceTask: bind the input (BindInput),EnqueueaJob{ID, Topic, Input},updateState(TrackWaitForEvent), and emit a newevJobWaitingtrackEventKind. - FR-5 —
WithWorkeris valid only on message-operations: a ServiceTask whoseoperation.Type() == gooper.GoOperTypewithWithWorkerset is a build-time error (a Go closure has no shippable message boundary). - FR-6 — A worker report re-enters the engine via
ReportJobCompletion(aJobCompletionSinkthe engine implements, §3.4): the dispatcher, onComplete/Fail, delivers aWorkerOutcometo the sink; the engine resolvesJob.ID→ the parked(instance, track)(a registry populated atEnqueue) andinst.emit(evDeliver, &WorkerOutcome, track). The parked track resumes:ServiceTask.ProcessEventstashes the outcome,Execbinds/faults. - FR-7 — Under
WithWorkerthe Operation's in-process executor is ignored (ADR-021 §2.5): the Operation contributes only its contract (inMessage→Job.Input;outMessage← the worker's output). A presentImplementoris never invoked — not a build error. - FR-8 — M2/M3 outcomes are two:
Complete(output)→ bindoutputtoDataOutput(the existingMustParameter/re.Putpath) and complete;Fail(cause)→ fault the task (Failing → Failed, a wrapped error). Classification (businessBpmnError/Status), output mapping, and retry are out of scope (SRD-037/038) — aFailhere is terminal, not yet retried.
Non-functional¶
- NFR-1 (dehydration-friendly) — a parked worker-waiting task holds no goroutine and no live call: only a queued job (in the store) and a parked track. (Contrast the rejected push model, ADR-021 §2.4.)
- NFR-2 (single-writer preserved) — worker reports re-enter only via
inst.emit; the loop remains the sole sender to anyt.evtCh(ADR-017). The dispatcher never touches instance internals — it calls the sink. - NFR-3 (liveness & crash-resilience) —
maxLockDurationcapsExtendLock(a hung/monopolising worker's job eventually re-enters the queue); an expired lock makes a job fetchable again (worker-crash recovery). - NFR-4 (gate) — diff-coverage ≥95% on touched files;
make cigreen; the store's lock/expiry and the park/resume path are-raceclean.
3. Models¶
3.1 The job-queue interface — pkg/tasks/workerdispatcher.go (REWORK)¶
// Named identifiers keep the extendable interface mixing-proof at compile time:
// a Topic can't be passed where a JobID is expected, and vice versa.
type (
JobID string // instance+track+node; the worker's idempotency key
Topic string // the fetch key (== a ServiceTask's WithWorker topic)
WorkerID string
)
// Policy is the per-service execution bundle shipped to a WorkerTrusted worker
// (SRD-038): output mapping + error mapping + retry policy. Nil under
// EngineAuthoritative and throughout M2/M3. A placeholder here; SRD-037 adds
// ErrorMapper/OutputMapping, SRD-038 adds RetryPolicy.
type Policy struct{ /* extended by SRD-037/038 */ }
// Job is the enqueued unit. Input is the single bound input-message item
// (nil if the operation has no inMessage).
type Job struct {
ID JobID
Topic Topic
Input *data.ItemDefinition
Policy *Policy
}
// LockedJob is a Job handed to a worker by FetchAndLock, plus its lock.
type LockedJob struct {
Job
WorkerID WorkerID
Deadline time.Time // extend before this, or the lock expires
}
// WorkerDispatcher is an asynchronous fetch-and-lock job queue (ADR-021 §2.4).
type WorkerDispatcher interface {
// engine → queue (non-blocking); the engine then parks the task.
Enqueue(ctx context.Context, job Job) error
// worker ← queue (pull); locks each returned job for lockDuration.
FetchAndLock(ctx context.Context, workerID WorkerID,
topics []Topic, lockDuration time.Duration) ([]LockedJob, error)
ExtendLock(ctx context.Context, jobID JobID, workerID WorkerID,
newDuration time.Duration) error
// worker → engine report (exactly one per job).
Complete(ctx context.Context, jobID JobID, workerID WorkerID,
output *data.ItemDefinition) error
Fail(ctx context.Context, jobID JobID, workerID WorkerID, cause error) error
}
(Report(status) / BpmnError(code) join in SRD-037 — an interface-method addition, contained to the single
impl + its mock.)
3.2 WorkerOutcome — the synthetic completion event¶
A worker report becomes a WorkerOutcome, a flow.EventDefinition delivered into the loop to resume the parked
track (mirroring interactor.TaskCompletion for UserTask). It lives beside the dispatcher.
// WorkerOutcome carries a worker's terminal report for job JobID. Exactly one
// of Output / Cause is meaningful. SRD-037 adds the classified variants
// (status value, BPMN errorCode).
type WorkerOutcome struct {
JobID JobID
Output *data.ItemDefinition // set on Complete
Cause error // set on Fail
// embeds the definition machinery implementing flow.EventDefinition
}
3.3 localdispatcher job store (REWORK)¶
type Dispatcher struct {
mu sync.Mutex
byTopic map[string][]*jobEntry // FIFO queue per topic
byID map[string]*jobEntry // ID → entry (lock/report lookup)
sink tasks.JobCompletionSink
maxLock time.Duration
// local worker pool (registered per-topic handlers) fetch-and-locks
}
type jobEntry struct {
job tasks.Job
workerID tasks.WorkerID // "" = unlocked
deadline time.Time // lock expiry
firstLock time.Time // for the maxLockDuration cap
}
New(...) takes the maxLockDuration cap and (during Thresher startup) the completion sink. FetchAndLock
skips locked-and-unexpired entries; Complete/Fail validate workerID == holder, then deliver a
WorkerOutcome to sink.
3.4 Completion sink + job registry (engine side)¶
// JobCompletionSink routes a worker's report to the owning instance. The engine
// implements it; the dispatcher calls it from Complete/Fail. (pkg/tasks)
type JobCompletionSink interface {
ReportJobCompletion(ctx context.Context, outcome WorkerOutcome) error
}
The engine records Job.ID → (instanceID, trackID) at Enqueue (in parkExternalServiceTask); ReportJobCompletion
looks it up and inst.emit(evDeliver, &outcome, track). Job.ID is minted from instance+track+node, so the
lookup is O(1) and needs no scan.
3.5 ServiceTask worker fields + wait-node hook (activities, EXTEND)¶
srvTaskConfig (added in M1) gains workerTopic tasks.Topic; WithWorker(topic string) SrvTaskOption sets it
(converting the ergonomic string literal to tasks.Topic) and marks the task external. ServiceTask exposes a
marker the track checks (mirroring interactor.HumanTask):
// WorkerTopic reports the external-worker topic and whether the ServiceTask is
// worker-dispatched. checkNodeType diverts a worker-dispatched task to the
// wait-node park path; an in-process task (ok == false) runs Exec as today.
func (st *ServiceTask) WorkerTopic() (topic tasks.Topic, ok bool)
ServiceTask.ProcessEvent (new, implements eventproc.EventProcessor) type-asserts *WorkerOutcome, stashes it;
Exec branches: worker-dispatched → bind the stashed Output (or fault on Cause); else the M1 in-process path.
3.6 Lifecycle¶
sequenceDiagram
participant IL as Instance loop (single writer)
participant Track as ServiceTask track
participant JQ as Job store (dispatcher)
participant W as Worker (pull)
IL->>Track: activate worker-dispatched ServiceTask
Track->>Track: BindInput, Enqueue(Job), park (TrackWaitForEvent)
Note over IL,JQ: engine records Job.ID → (instance, track)
W->>JQ: FetchAndLock(workerID, topics, lockDuration)
JQ-->>W: LockedJob
Note over W: execute (ExtendLock if long)
alt success
W->>JQ: Complete(jobID, workerID, output)
JQ->>IL: ReportJobCompletion(WorkerOutcome output)
IL->>Track: evDeliver → resume → ProcessEvent stash → Exec bind → complete
else technical fault
W->>JQ: Fail(jobID, workerID, cause)
JQ->>IL: ReportJobCompletion(WorkerOutcome cause)
IL->>Track: evDeliver → resume → Exec fault → Failing → Failed
end
4. Analysis¶
4.1 Report delivery — a sink, not the dispatcher touching instances (FR-6, NFR-2)¶
The dispatcher must stay decoupled from instance internals (it may be a remote adapter later). So Complete/Fail
deliver a WorkerOutcome to an engine-provided JobCompletionSink; the engine (which owns the Job.ID →
(instance, track) registry) does the inst.emit. This preserves the single-writer loop (NFR-2) and keeps the
queue a pure transport. Rejected: the dispatcher calling inst.emit directly — couples the queue to instance
internals and breaks the remote case.
4.2 WorkerOutcome as a flow.EventDefinition (FR-6)¶
Reusing the ADR-017 park/resume delivery means the report must arrive as an event the parked track's
ProcessEvent consumes — exactly the interactor.TaskCompletion shape UserTask uses. A synthetic
WorkerOutcome event definition is the minimal, precedented choice. Rejected: a bespoke resume channel — would
duplicate the loop-delivery machinery ADR-017 already owns.
4.3 Park-and-enqueue at checkNodeType; bind output at Exec on resume (FR-4, FR-7)¶
Input binding + Enqueue happen at park time (parkExternalServiceTask), so the job carries the bound input
and the engine holds no call. Exec runs on resume and binds the stashed output (or faults) — it does
not run op.Execute for a worker-dispatched task (FR-7: the worker is the executor). This matches UserTask
(Exec binds stashed completedOutputs).
4.4 Fail faults now; retry arrives in SRD-038¶
Without the retry policy (SRD-038), a Fail is terminal: Exec returns a wrapped error →
Failing → Failed. SRD-038 re-routes Fail through the retry policy (re-enqueue with backoff) — a change to how
the outcome is handled, not to the report call. Forward-compatible.
4.5 Named identifier types over bare strings¶
JobID / Topic / WorkerID are named string types, not bare strings (§3.1). For an interface meant to
grow (remote adapters, more report kinds — SRD-037/038, ADR-004), the compile-time guard is worth the small
verbosity: the type checker rejects passing a Topic where a JobID is expected, catching argument-order
mistakes for free. This is the same instinct as the concrete *data.ItemDefinition (over the stub's any) — a
durable, extendable contract should carry meaning in its types. WithWorker still takes an ergonomic string at
the public boundary (a string literal converts to tasks.Topic). Rejected: bare strings — convenient, but an
extendable queue interface with several string parameters is exactly where silent argument-swaps hide.
4.6 What stays the same¶
The in-process locus (M1 WithTimeout + synchronous op.Execute) is untouched — checkNodeType diverts only a
worker-dispatched ServiceTask (WorkerTopic() ok). The operation contract, BindInput/output binding, and the
options type-switch are reused unchanged.
5. API / contract surface¶
- Reworked:
pkg/tasks.WorkerDispatcher(job-queue interface), the named idspkg/tasks.JobID/Topic/WorkerID,pkg/tasks.Job/LockedJob/Policy(new/changed),pkg/tasks.WorkerOutcome,pkg/tasks.JobCompletionSink. - New:
activities.WithWorker(topic string) SrvTaskOption;ServiceTask.WorkerTopic(),ServiceTask.ProcessEvent(...); the engine'sReportJobCompletion. - Reworked impl:
pkg/tasks/localdispatcher(job store). Wiring (defaultConfig,renv.EngineRuntime) keeps the accessor shape.
6. Test scenarios¶
| Test | FR/NFR | Scenario |
|---|---|---|
TestDefaultRuntimeWorkerDispatcherIsJobStore |
FR-3 | a default runtime's WorkerDispatcher() returns the reworked localdispatcher job store (satisfying the new interface); WithWorkerDispatcher overrides it |
TestLocalDispatcherEnqueueFetchComplete |
FR-1, FR-2 | enqueue → fetch-and-lock → complete delivers a WorkerOutcome to the sink |
TestLocalDispatcherLockExpiryRefetch |
FR-2, NFR-3 | a locked job whose lock expires is fetchable again |
TestLocalDispatcherExtendLockHolderOnlyAndCap |
FR-2, NFR-3 | non-holder ExtendLock fails; extension past maxLockDuration is refused |
TestLocalDispatcherFetchOnlyRequestedTopics |
FR-2 | FetchAndLock returns only jobs for the requested topics |
TestServiceTaskWithWorkerRejectsGoOperation |
FR-5 | WithWorker on a goOperation → build-time error |
TestServiceTaskWorkerParksAndEnqueues |
FR-4, NFR-1 | activation parks (TrackWaitForEvent) + enqueues a Job with the bound input; no goroutine held |
TestServiceTaskWorkerCompleteResumesAndBinds |
FR-6, FR-8 | a Complete report resumes the parked track and binds the output to DataOutput |
TestServiceTaskWorkerFailFaults |
FR-6, FR-8 | a Fail report faults the task (Failing → Failed) |
TestServiceTaskWorkerExecutorIgnored |
FR-7 | a worker-dispatched op with a present Implementor never invokes it |
TestReportJobCompletionRoutesToParkedTrack |
FR-6, NFR-2 | ReportJobCompletion resolves Job.ID → the parked track and delivers via inst.emit |
7. Milestones¶
- M2 —
WorkerDispatcherjob-queue interface +localdispatcherjob store (store, lock/expiry,ExtendLockcap, local pool) +WorkerOutcome/JobCompletionSinktypes + wiring. One commit. - M3 —
WithWorker+parkExternalServiceTask(checkNodeTypebranch,evJobWaiting) +Enqueueat park +ReportJobCompletion+ServiceTask.ProcessEvent/Execresume-bind + the message-operation build guard. One commit.
8. Cross-doc¶
- Implements: ADR-021 v.1 §2.1–§2.5.
- References (up / sideways): ADR-001 v.6 (execution model), ADR-017 v.1 §2 (wait-node park/resume + loop delivery), ADR-018 v.1 (fault path), ADR-020 v.1 (UserTask wait-node template), ADR-011 v.5 (operation / data binding), SAD-001 v.1 §11, §13.
- Sibling SRDs: SRD-035 (M1, Accepted); SRD-037 (M4/M5 — classification + output mapping), SRD-038 (M6–M8 — retry + trust + example) forthcoming. SRD→SRD sideways; pins by number.
- Backlog: AB-005 (structured
ItemDefinitioncompose/spread — multi-variable data binding) is a data-layer follow-up, out of scope here. - Direction: SRD → ADR / SAD (up), SRD → SRD (sideways); no downward reference.
9. Definition of Done¶
- FR-1…FR-8 implemented and wired; NFR-1…NFR-4 upheld.
- Every FR/NFR covered by ≥1 named §6 test, all green under
-race. - The old
Register/Dispatch/Handlersurface andJob.Input anyare removed; no stale callers. make cigreen (tidy · lint · build ·-race· diff-coverage ≥95% on touched files · govulncheck).- SRD-036 flips to Accepted. ADR-021 stays Draft until SRD-037/038 are grounded.
10. Implementation summary (stage-by-stage actual landings + deltas vs draft)¶
10.1 Stage commits (branch feat/service-task-execution)¶
| Stage | Commit | Scope | Key tests |
|---|---|---|---|
| M2 | 237d6fc |
WorkerDispatcher job-queue interface + localdispatcher job store (topic queue, per-job lock/expiry, holder-only ExtendLock + maxLockDuration cap, local worker pool) + WorkerOutcome / JobCompletionSink types + wiring |
TestLocalDispatcher{EnqueueFetchComplete, LockExpiryRefetch, ExtendLockHolderOnlyAndCap, FetchOnlyRequestedTopics, …} |
| M3 | 24cfa36 |
WithWorker + parkServiceTask (checkNodeType branch, evJobWaiting) + loop onJobWaiting (bind + Enqueue) + ReportJobCompletion routing (Thresher sink + jobID-embedded instance id) + ServiceTask.ProcessEvent/Exec resume-bind + message-operation build guard |
TestServiceTaskWorker{ParksAndEnqueues, CompleteResumesAndBinds, FailFaults, ExecutorIgnored, ExecBindsCompletedOutput, ExecFaultsOnCause, BoundaryInterruptDropsJob, EnqueueFailureFaults, BindInputFailureFaults}, TestReportJobCompletion{RoutesToParkedTrack, CanceledContext}, TestThresherReportJobCompletionRoutes, TestServiceTaskWithWorkerRejectsGoOperation, TestMakeJobIDRoundTrip, TestDefaultRuntimeWorkerDispatcherIsJobStore |
make ci green — lint/vet/build/-race clean, govulncheck clean, diff-coverage 97.7% of 519 changed lines (min 95%).
10.2 Empirical findings — where the implementation refined the §3/§4 draft¶
Both refinements preserve every required invariant (single-writer NFR-2, park/resume, dehydration-friendliness NFR-1) and are recorded here rather than by rewriting §3/§4 (one-shot doc). The draft used the UserTask-analogy prose; the code sharpened it.
- Bind +
Enqueuemoved from park (track) to the loop'sonJobWaitinghandler (FR-4, §4.3). The draft placed input binding +EnqueueinsideparkExternalServiceTaskon the track. The implementation splits it:parkServiceTask(internal/instance/track.go) only mints theJobID, parks (TrackWaitForEvent), and emitsevJobWaiting; the loop'sonJobWaiting(internal/instance/jobs.go) opens a frame, binds the input, andEnqueues — on the loop goroutine. Why: scope access (the frame) stays single-writer on the loop, exactly likeauthorizeTaskfor a UserTask; binding on the parked track's goroutine would cross that boundary. A bind/enqueue failure delivers a syntheticFailoutcome so the task faults rather than parking forever. -
Two supporting seams were added (not named in the draft):
service.Operation.BindInputOnly(ctx, r)— bind the input message without running the executor (the worker is the executor) — on bothmessageOperationandgoOperation; and atasks.ExternalWorkerinterface (WorkerTopic+BindJobInput) thatcheckNodeTypetype-asserts, decouplinginternal/instancefrom the concreteServiceTask. -
Report re-entry uses a dedicated
jobReqchannel +handleJobCompletion, notevDeliver; routing is by an instance id embedded in theJobID, not a separate registry (FR-6, §3.4/§4.1). The draft describedinst.emit(evDeliver, &outcome, track)and aJob.ID → (instanceID, trackID)registry populated atEnqueue. The implementation mirrors the UserTask completion path instead:Instance.ReportJobCompletion(ctx, *WorkerOutcome)(a pointer, not the §3.4 value) hands the outcome to the loop over a dedicatedjobReqchannel (liketaskReq), andhandleJobCompletionresolves it against a loop-ownedjobs map[JobID]*trackand delivers on the track'sevtCh. Cross-instance routing (one shared dispatcher, many instances) is solved by embedding the owning instance id in theJobID(tasks.MakeJobID/JobID.InstanceID): the Thresher implementstasks.JobCompletionSink, is bound atNewviaSinkBinder, and forwards to the owning instance — the WorkerDispatcher analogue of the UserTaskroutingDistributor. Why: a completion is a distinct control signal, not a generic hub event; the dedicated channel keeps it explicit, and the self-routingJobIDneeds no separate registry.
10.3 Out-of-scope hardening folded in¶
- The
internal/enginertfluent override setters (WithClock/WithLogger/WithExpressionEngine/WithWorkerDispatcher) were hardened to ignore a nil argument and keep the bundled default rather than erasing it — the FIX-020 bug class (a public setter must not let bad input silently replace a working default). A fluent setter cannot report an error, so keep-default is the honoring; the public option API (thresher.WithWorkerDispatcher) already rejects nil with an explicit error.
10.4 Coverage-gate note¶
The diff-coverage gate is measured per-package (make test-all runs go test -coverprofile ./... without -coverpkg). The ServiceTask worker methods (execWorkerOutcome, ProcessEvent, the Exec worker branch) and Operation.BindInputOnly are exercised end-to-end by the internal/instance wait-node tests, but that cross-package coverage is not attributed to the activities / service packages. Same-package unit tests were therefore added (service_task_worker_test.go Exec/ProcessEvent cases; operation_test.go / gooper_test.go BindInputOnly) so each package's own coverage carries these methods and the per-package gate holds.