Files
zeekayandHanzo Dev 3d729057eb Enforce each namespace's retention
WorkflowExecutionRetentionTtl (default 720h) was stored and read nowhere,
so finished runs and every key they own stayed forever.

runPurge, beside the cron sweeper, now runs every 5 s across every tenant.
Each pass reads up to 512 of a namespace's run rows, workflow runs then
standalone activities, from a cursor at purge/<ns>, and removes each
terminal run whose close time plus the retention has passed: wf, wfh,
wfact, wfopen and the idem and sctrig entries naming it, or act, ahist and
the aidem entries naming it. Every delete goes through the replicated
delete path, the row last, so a pass cut short leaves the row to find the
rest by. The row is re-read under the run lock first; an open run is never
touched. A retention that does not parse purges nothing and is reported.

Each pass ends in Shard.Reclaim, and a default-mode shard is rebuilt at
the end of a walk, when it is smallest, so the file shrinks.

On a 1.71 GiB shard of 200k expired runs and 10 open ones, 392 passes
removed them (p99 197 ms a pass), the file went to 132 KiB, and Embed took
5 ms and 16 MB peak RSS.

Co-authored-by: Hanzo Dev <[email protected]>
2026-10-03 12:19:56 -07:00

649 lines
35 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Hanzo Tasks
Durable workflow execution engine for AI agent orchestration.
**Upstream**: Temporal (MIT). Pre-v3.0 history is the Temporal fork; v3.0+ is native ZAP-only with the upstream gRPC/proto/runtime fully ripped out. Public Go module pinned at v1.x forever (semver-import-versioning policy).
## Module
`github.com/hanzoai/tasks`
## Versioning policy (2026-04-30)
- **Stay on v1.x.x forever.** Per global CLAUDE.md "NEVER bump Go packages above v1.x.x."
- Major version bump in semver-import-versioning would require module-path suffix (`/v2`, `/v3`); we don't do that.
- Old v2.x and v3.x tags (transition era) were deleted on 2026-04-30. Old v1.x tags (`v1.0.0`–`v1.42.0`) are upstream-Temporal-era artifacts and remain in git history for blame.
- New Hanzo-Tasks releases continue from `v1.43.0` and bump minor for features, patch for fixes.
- `v1.51.2` added `Embedded.CancelActivityForOrg`; `v1.51.3` added `Embedded.ActivitiesPageForOrg` — the org-scoped cancel + paginated read cloud's fleet queue surface (hanzoai/cloud `clients/visor`) depends on.
- `v1.52.3` added `View.FailureStreaks(ns)` + the `FailureStreak` type — the durable "what is broken right now" read a host polls instead of hand-decoding SQLite.
## Recovery and retention (v1.53.6)
- **Recover reads only open runs.** `wfopen/<ns>/<workflowId>/<runId>` is put
before a run's first row and deleted after its terminal row
(`openRun`/`closeRun`, `durable.go`). Recover lists it, so boot cost follows
what is in flight, not how much history a shard holds. A shard written before
the index is walked once (`indexOpenRuns`, paged, minimal decode) and marked
`wfidx/<ns>`. Recover still covers the root tenant only, as before.
- **Retention is enforced.** `runPurge` (every 5 s, `retention.go`) walks each
namespace's `wf/` then `act/` rows, 512 per pass per shard, from a cursor at
`purge/<ns>`, across every tenant. A terminal run whose close time plus
`WorkflowExecutionRetentionTtl` (default `720h`) has passed is removed with
every key it owns: `wf`, `wfh`, `wfact`, `wfopen`, the `idem` and `sctrig`
entries naming it; for standalone activities `act`, `ahist`, `aidem`. All
through the replicated delete path, row last. The row is re-read under the
run lock first; an open run is never touched. A retention that does not parse
purges nothing and logs `tasks: retention purge failed` (throttled).
- **The file shrinks.** Each pass ends in `Shard.Reclaim`. A default-mode
shard is rebuilt once, at the end of a walk, with `VACUUM` when at least a
quarter of it is free; that switches it to incremental auto-vacuum, and from
then on each pass releases up to 4096 pages. The smaller file reaches disk at
the next seal.
- **Measured** (200k terminal runs + 10 open, 1.71 GiB encrypted shard, pure-Go
envelope, 8 vCPU): `Embed` 10.4–12.0 s / VmHWM 1.14–1.25 GB before;
7.7–8.7 s / 85 MB on the first boot (backfill walk); 6.0–6.2 s / 21 MB after.
What is left is the envelope decrypting the whole file at open (~3.5 s/GiB,
single-threaded). The purge drained the 200k runs in 392 passes (p99 197 ms),
the shard went to 132 KiB, and `Embed` took 5 ms / 16 MB.
- **Not here:** the envelope's minute seal (`hanzoai/sqlite` `Checkpoint`)
re-encrypts the whole file while holding the shard's one connection, about
9 s per minute at 1.7 GiB, whether or not anything changed.
## Cron sweeper observability (v1.52.2)
The sweeper reports what it cannot return to a caller. Three `slog` messages
off `EmbedConfig.Logger` (`slog.Default()` when unset), all from
`pkg/tasks/engine.go`:
| Message | Level | Meaning |
|---------|-------|---------|
| `tasks: cron schedule action failed` | ERROR | One schedule's fire did not reach a worker. Carries `org`, `namespace`, `scheduleId`, `workflowType`, `taskQueue`, `consecutiveFailures`, `error`. |
| `tasks: cron schedule action recovered` | INFO | A previously failing schedule fired and reached a worker. Carries `afterConsecutiveFailures`. |
| `tasks: schedule sweep failed, no schedule fired this tick` | ERROR | The whole sweep failed — cron is dead for every org at once. |
A fire fails in one of two ways, and both produce the same line because the
operator's question is the same: **the start itself errored**, or **the start
succeeded and no worker is subscribed to `(namespace, taskQueue)`** — the run
exists and nothing will ever execute it. The `error` field says which. The
second half catches a cron naming a namespace nobody serves (namespaces are
created on first use, so a typo is no longer a start error), a schedule whose
worker died, and a task queue renamed out from under an entry. The check is
made AFTER the start, because enqueueing prunes subscriptions whose peer the
transport can no longer reach.
Alert on the first; `consecutiveFailures` is the "how long has this been
dead" number. Per-schedule **isolation is unchanged** — a broken entry is
still skipped and retried next sweep; only the silence is gone.
Anchoring follows the start, not the report: a fire that ERRORED is not
re-anchored (it stays due and retries every tick), a fire that STARTED is
re-anchored even when it went unreached — the run exists, and re-firing on
the strength of a report would spawn one every 5s.
Throttle: first failure, then one line per `failReportThrottle` (60) sweeps.
Unthrottled a broken 5s-tick schedule is ~17k lines/day. The counter clears
on a reached fire and on `DeleteSchedule`, so a relapse alerts immediately.
Why this exists: cloud's `clients/cron` fired a JobWorkflow whose activity
re-read the entry's ConfigMap; the cloud ServiceAccount had no RBAC to read
ConfigMaps, so every fire failed for **11 days** across six nightly backups
while the engine emitted nothing and every dashboard read healthy. It was
found only by hand-reading the engine's durable records.
## Activity / workflow failure observability (v1.52.3)
The sweeper fix above covers the SCHEDULER. It does not cover the incident:
`StartWorkflow` **succeeded** all 4489 times, so the scheduler was healthy
and correctly silent — the fault was one layer down, in the ACTIVITY. That
layer's only output was `e.emit(Event{Kind:"activity.failed"})` into the SSE
broker: ephemeral, dropped when nobody is subscribed, and nobody was.
**Guarantee: durable evidence, plus throttled log lines.** Both, not either.
### Durable — `fail/<ns>/<fingerprint>`, read via `View.FailureStreaks(ns)`
One `FailureStreak` row per failing identity, upserted on every failure and
**deleted on the first success**. It needs no subscriber and survives
restart, so `ConsecutiveFailures` can say `4489` where an in-memory counter
would have said `1`. A non-empty listing IS the list of what is broken; the
keyspace is bounded by how much is broken now, not by how often it failed
(so it does not have the unbounded-growth problem noted under Known gaps).
Identity is the recurring **shape** of the work — `(workflowType,
activityType, taskQueue, scheduleId)` — never the run: every cron fire mints
a fresh runId, so a run-keyed counter can never say "dead for 11 days".
Each row carries `org`, `namespace`, `workflowType`, `activityType`,
`taskQueue`, `scheduleId`, `consecutiveFailures`, `attempt`, `retrying`,
`persistent`, `firstFailureTime`, `lastFailureTime`, `lastError`, and
`lastWorkflowId`/`lastRunId` to go read.
`ActivityType` empty ⇒ the **workflow itself** failed (a decider that faults
before scheduling anything — the hole an activity-only report would leave).
### Log lines — `pkg/tasks/failure.go`
| Message | Level | Meaning |
|---------|-------|---------|
| `tasks: activity failed` | WARN | An activity attempt failed. `retrying` says whether the engine will try again. |
| `tasks: activity failing persistently` | **ERROR** | **Alert on this.** Failing continuously for ≥ `failPersistentAfter` (15 min). |
| `tasks: activity recovered` | INFO | Streak ended. Carries `afterConsecutiveFailures`. |
| `tasks: workflow failed` / `… failing persistently` / `… recovered` | WARN / ERROR / INFO | Same three, for a workflow failing with no activity to blame. |
**WARN vs ERROR is the distinction the incident turned on**: "failed once and
will retry" is normal operation and must not page anyone; "has failed every
attempt for a long time" is the outage. Persistence is measured by **age,
not count** — a nightly backup reaches a count of only 2 in two days yet is
unambiguously broken, while one run burning its 10 default attempts (≈5.5
min of backoff) must not trip it. The persistent **transition** always
reports, whatever the throttle says; waiting for failure #60 would be the
original bug wearing a different hat.
### Throttle
`shouldReport` is the one rule for the whole package (schedules included):
first failure, then every `failReportThrottle` (60). Measured: 180 failures
→ **4 lines**; stubbing the throttle off → 180. The throttle bounds LINES
only — the durable count stays exact.
Two counters on purpose: the in-memory `failTracker` is the throttle
authority (a store that is itself failing cannot starve it into reporting
every occurrence, and a restart makes a still-broken activity re-alert once
after a deploy), while the durable row is the evidence and always supplies
the count on the line.
### Not changed
Retry and isolation **semantics are identical** — which branch is taken,
when, and how many times. `TestFailureReportingPreservesRetrySemantics`
pins the attempt count, terminal status, history events and dispatch count.
The SSE `emit` calls all remain.
Schedule-started runs now carry a `ScheduleId` search attribute (sweeper and
`TriggerSchedule`), which is what lets a failure name the dead cron entry —
and makes `ScheduleId = "nightly-backup"` a queryable filter over executions.
## Quick Start
```bash
go build ./cmd/tasksd/
./tasksd start
```
## Integration
- Playground connects to the Hanzo Tasks server via the durable-execution SDK
- Base embeds Tasks for durable cron/batch execution
- Each playground space = a Tasks namespace
- Each agent = a Tasks worker
## Rebrand Notes (2026-03-19, updated 2026-04-26)
- Upstream server packages replaced with `github.com/hanzoai/tasks` in all Go files, go.mod, go.sum
- `cmd/server` renamed to `cmd/tasksd`, binary name is `tasksd`
- Docker images: `ghcr.io/hanzoai/tasks`
- 2026-04-26: dropped compat. `temporal/`→`tasks/`, `temporaltest/`→`tasktests/`,
`tests/` (upstream-compat suite, 3.7 MB) deleted. No backward-compat shim
remains. `tests/testutils/` retained (cert/TLS/IO helpers used by real
unit tests).
## Production Deployment (2026-03-19)
### Live at tasks.hanzo.ai
- **Cluster**: hanzo-k8s (do-sfo3-hanzo-k8s), namespace `hanzo`
- **Server**: `ghcr.io/hanzoai/tasks:latest` -- ZAP on 9999, HTTP on 7243.
`--zap` takes an ADDRESS: `:9999` for TCP, or a path to bind a unix
socket for service-to-service traffic that never leaves the host. A
socket path must fit `sockaddr_un.sun_path` — 107 bytes plus its NUL —
which a socket nested under a deployment's data directory can exceed;
`Embed` and `ServeGated` refuse an oversized one by name, because the
kernel's own answer is `bind: invalid argument` and nothing more.
- **UI**: `ghcr.io/hanzoai/tasks-ui:latest` -- port 8080
- **Database**: PostgreSQL at `sql.hanzo.svc:5432` (databases: `tasks`, `tasks_visibility`)
- **Secrets**: KMS-managed via `tasks-secrets` (POSTGRES_PASSWORD, TASKS_AUTH_SECRET, IAM_CLIENT_SECRET)
### Domains
| URL | Service | Protocol |
|-----|---------|----------|
| tasks.hanzo.ai | tasks-ui (Web UI) | HTTPS |
| tasks-api.hanzo.ai | tasks (ZAP) | ZAP+TLS |
### IAM Integration
- **Provider**: hanzo.id (OIDC)
- **Client ID**: `app-tasks`
- **Callback**: `https://tasks.hanzo.ai/auth/sso/callback`
- **Scopes**: openid, profile, email
- **JWKS URI**: `https://hanzo.id/v1/iam/.well-known/jwks` (in-cluster: `http://iam.hanzo.svc/v1/iam/.well-known/jwks`)
- **Discovery**: `https://hanzo.id/.well-known/openid-configuration`
- **Registration script**: `scripts/register-iam.sh`
#### Auth Flow (two layers)
1. **UI (OIDC login)**: Tasks UI (`tasks-ui` container) handles the browser OIDC flow.
User visits tasks.hanzo.ai, gets redirected to hanzo.id for login, callback returns
JWT tokens. Configured via `TASKS_AUTH_*` env vars on the tasks-ui container.
2. **Server (JWT validation)**: Tasks server validates the JWT bearer token on every ZAP/HTTP
request using JWKS keys fetched from hanzo.id. Configured via the wire-protocol env vars
below (legacy names preserved for upstream config compatibility). These env vars feed into
the embedded config template at `common/config/config_template_embedded.yaml` ->
`global.authorization`.
#### Embedded Config Template Auth Env Vars (server)
> Canonical `TASKS_*` env var names. The binary reads these directly via the embedded
> config template parser.
| Env Var | Purpose | Value in K8s |
|---------|---------|-------------|
| `TASKS_JWT_KEY_SOURCE1` | JWKS URI for key fetching | `http://iam.hanzo.svc/v1/iam/.well-known/jwks` |
| `TASKS_JWT_KEY_REFRESH` | Key refresh interval | `5m` |
| `TASKS_AUTH_AUTHORIZER` | Authorizer type | `default` |
| `TASKS_AUTH_CLAIM_MAPPER` | Claim mapper type | `default` |
| `TASKS_JWT_PERMISSIONS_CLAIM` | JWT claim for permissions | `permissions` |
#### UI Auth Env Vars (tasks-ui container)
| Env Var | Purpose | Value in K8s |
|---------|---------|-------------|
| `TASKS_AUTH_ENABLED` | Enable OIDC login | `true` |
| `TASKS_AUTH_PROVIDER_URL` | OIDC issuer | `https://hanzo.id` |
| `TASKS_AUTH_CLIENT_ID` | OIDC client ID | `app-tasks` |
| `TASKS_AUTH_CLIENT_SECRET` | OIDC client secret | (from tasks-secrets) |
| `TASKS_AUTH_CALLBACK_URL` | OIDC callback | `https://tasks.hanzo.ai/auth/sso/callback` |
| `TASKS_AUTH_SCOPES` | OIDC scopes | `openid,profile,email` |
#### Namespace-to-Org Mapping
Tasks namespaces map 1:1 to Hanzo orgs. Users see only namespaces matching their IAM
org memberships. The JWT `permissions` claim carries `namespace:role` pairs (e.g.,
`hanzo:admin`, `lux:read`). The default claim mapper parses these into the internal
permission model.
### K8s Manifests
- Canonical source: `k8s/` in this repo
- Also mirrored in: `universe/infra/k8s/tasks/`
- Apply with: `kubectl apply -k k8s/`
- DB init (one-time): `kubectl apply -f k8s/init-db.yaml`
### CI/CD
One way, and it runs on our own stack:
push -> github.com/hanzoai/tasks (a mirror)
.github/workflows/sync.yml carries refs onward
-> git.hanzo.ai/hanzoai/tasks CANONICAL
.hanzo/workflows/cicd.yml tests + builds ghcr.io/hanzoai/tasks
-> hanzoai/cloud go.mod pins the tag this repo cuts
**git.hanzo.ai is canonical; GitHub is a mirror.** `.github/workflows/` holds
exactly one file, `sync.yml`, and its only job is getting refs to the forge. Every
build, check and deploy is a workflow under `.hanzo/workflows/`, which the forge
reads. `.hanzo/workflows` uses GitHub Actions syntax, so a workflow moves between
the two by changing directory and nothing else.
`.hanzo/workflows/cicd.yml` is a thin trigger; all config lives in the repo-root
`hanzo.yml` — `go test -race ./pkg/... ./cmd/...` plus `go vet`, and the image
`ghcr.io/hanzoai/tasks` from the root `Dockerfile`. `hanzo.yml` declares no
`deploy:` key, so **cicd.yml never deploys**: it publishes an image and stops.
What this repo is actually load-bearing for is the **Go module tag**, which
`hanzoai/cloud` `go.mod` pins. Tags are cut by hand; CI only stamps image tags off
a tag that already exists.
The `tasks` image has no live consumer today. `tasks.hanzo.ai` is served by
cloud's embedded `clients/tasks/ui`, not by this image, and there is no App CR for
it in `hanzoai/universe`.
### Observability
- OTEL traces: `otel-collector.hanzo.svc:4318`
- Insights analytics: `insights-capture.hanzo.svc:3000`
- Dynamic config: `/etc/tasks/dynamic-config/dynamic-config.yaml` (ConfigMap)
## Native ZAP-only (2026-04-26)
The temporal fork is GONE. Zero `go.temporal.io/*`. Zero
`google.golang.org/grpc`. Zero protobuf on the wire. The whole binary is:
```
cmd/tasksd # 100 lines: signal handling, ZAP node, HTTP server
pkg/tasks/ # in-process server (zap.Node + opcode dispatch)
pkg/sdk/ # client + worker + workflow + activity + converter
ui/ # embedded React SPA (Vite bundle)
schema/tasks.zap # canonical wire schema
```
Build proof: `go build ./cmd/tasksd` → 10 MB native binary, 208 deps.
Boot proof: `tasksd --zap :9999 --http :7243` listens on both,
serves `/healthz`, `/v1/tasks/health`, `/_/tasks/*` (UI), responds to
ZAP opcodes 0x0050–0x00A5 from `pkg/sdk/client`.
The native engine is built and GREEN (see "## Durable Engine" below for
the true current state and the Phase-2 event-sourced design). Only one
opcode still returns 501: `OpcodeStartChildWorkflow` (0x006D). Everything
else — ExecuteWorkflow, activity dispatch, signal, cancel, terminate,
query, reset, schedules, namespaces, deployments — is real and tested.
### What was deleted (2026-04-26)
- `tasks/` (renamed temporal/, the fork's runtime)
- `tasktests/` (renamed temporaltest/)
- `service/` (frontend / history / matching / worker — 817 files)
- `chasm/` (component state machine framework — 169 files)
- `client/` (legacy gRPC clients — 40 files)
- `api/` (local mirror of temporal protos — 114 files)
- `proto/`, `tools/` (codegen for temporal protos)
- `cmd/tools/` (genrpcwrappers, genrpcserverinterceptors, getproto, etc.)
- `components/` (nexusoperations, callbacks, dummy state machines)
- `docker/` (pre-native server containerization)
- `schema/{cassandra,elasticsearch,mysql,postgresql,sqlite}/` (DB driver
schemas — embedded SQLite returns via pkg/tasks when persistence lands)
- 40 tainted `common/` subdirs + 7 top-level common files
(`util.go`, `rpc.go`, `rpc_mock.go`, `client_cache.go`, `daemon.go`,
`constants.go`)
- `tests/` (upstream-compat suite, 3.7 MB)
### What remains in `common/`
Pure stdlib utilities that survive the rip: `aggregate`, `auth`, `build`,
`channel`, `circuitbreaker`, `clock`, `collection`, `contextutil`,
`convert`, `debug`, `definition`, `effect`, `finalizer`, `future`, `goro`,
`health`, `masker`, `number`, `pingable`, `pprof`, `predicates`, `quotas`,
`resolver`, `routing`, `schedules`, `shuffle`, `stream_batcher`, `tasks`,
`tasktoken`, `timer`, `util`, `versioninfo`. These are candidates for
further pruning once the native engine settles.
### Auth: IAM only, through the estate's one reader
tasksd validates `Authorization: Bearer <jwt>` itself — no gateway required — and
the strip+write pattern is the trust boundary. What it does NOT do any more is hold
its own reading of that contract.
**The reader is `hanzoai/authz` + `hanzoai/authz/edge`.** `pkg/auth` is down to the
part that is genuinely tasksd's: which environment variables the policy comes from.
It had 190 lines of JWKS cache and JWT verification, and two defects a shared reader
does not have:
- it verified against **every key** in the published set and took the first that
worked, so a token naming `kid` A was accepted on a signature from key B. Naming a
key is what `kid` is for.
- its issuer comparison was **conditional on the configured issuer being non-empty**,
so an unset `TASKSD_JWT_ISSUER` accepted tokens from any issuer instead of failing
closed.
It also stripped only the four names it reads. Every other written header —
`X-User-IsAdmin` and `X-User-Permissions` (the platform and money signals),
`X-User-Owner`, `X-Billing-Account-Id`, `X-Scope`, `X-Workspace-Id`, and the retired
names — passed through from the client untouched, and since tasksd's ingress routes
straight to the Service, nothing upstream was deleting them either. It now strips
`authz.Headers` ∪ `authz.Retired` and writes through `edge.Apply`, so the strip list
IS the write list.
And it gains a machine predicate it never had: a `client_credentials` token carries no
membership set, so it is not a person and holds neither admin scope.
Per-org store scoping is `engine.As(Org(auth.OrgID(ctx)))`, resolved through
`Claims.EffectiveOrg("")` on BOTH transports — the one function that answers "which
org does this act in". tasksd honours no org switch, so that resolves the home org,
which is the first entry of the SIGNED MEMBERSHIP SET. It is deliberately NOT the
`owner` claim: IAM stamps the org of the APPLICATION a token was minted through
there, so reading it would make a person's namespace follow whichever app they
signed in through. A token with no membership set states no org and is refused.
The frontend scopes to the ORG; a host wanting project- or user-level isolation asks
for it explicitly via `Embedded.View(Principal{Org, Project, User})`.
`TASKSD_REQUIRE_IDENTITY=true` (production) rejects requests without a validated JWT.
Default false keeps the embedded/dev path working.
Cost, measured: 342 linked packages and an 18.3 MB binary, DOWN from 345 and 18.6 MB
— dropping go-jose more than paid for the leaf. (The "10 MB, 208 deps" figure earlier
in this document was already stale before this change.)
Production env (do-sfo3-hanzo-k8s/hanzo):
```
TASKSD_JWKS_URL=http://iam.hanzo.svc/v1/iam/.well-known/jwks
TASKSD_JWT_ISSUER=https://hanzo.id
TASKSD_REQUIRE_IDENTITY=true
```
History — pre-v3.5.0 the middleware trusted client-supplied `X-*` headers on the
assumption that hanzoai/gateway sat between ingress and tasksd. The actual topology
routed direct to the Service, leaving `X-Org-Id` spoofable on `tasks-api.hanzo.ai`.
v3.5.0 made tasksd self-sufficient; this change makes it self-sufficient *without*
being a fourth opinion about what an IAM token means.
## Durable Engine — true state + Phase-2 event-sourcing design
> Authoritative architecture note (CTO). Supersedes the stale "handlers
> return a 501" text above. Read this before touching `pkg/tasks` or the
> workflow runtime in `pkg/sdk`.
### Where the engine actually is (corrected)
The native engine is NOT a shell of 501 stubs. It is **Phase-1-complete
and green** (`go test -race ./pkg/tasks/... ./pkg/sdk/...` passes). Built
and tested today:
- `ExecuteWorkflow` — `engine.startWorkflowWithRequestID`: persists a
`WorkflowExecution` (SQLite), writes `WORKFLOW_EXECUTION_STARTED` to an
append-only history, mints a run id, enqueues a workflow task to a
subscribed worker. Idempotent on `(ns, workflowId, requestID)` via the
`idem/` keyspace.
- Append-only history — `engine.appendHistory` + `GetWorkflowHistory`
(paginated, reversible). `HistoryEvent{EventId, EventTime, EventType,
Attributes}`, `EventId` monotonic per `(ns, wf, run)`.
- Signal / Cancel (two-phase CANCEL_REQUESTED→CANCELED handshake with a
sweep) / Terminate / Query (server pushes the query to a worker and
awaits its response) / Reset (truncate history at an eventId).
- Standalone activities (`activities.go`): first-class activity records
with `ACTIVITY_TASK_SCHEDULED/STARTED/COMPLETED/FAILED` history.
- Worker dispatch (`dispatch.go`): workers `Subscribe` per (ns, queue,
kind); server pushes tasks (no polling); HMAC task tokens; round-robin;
pending queues drain on subscribe; activity results pushed back to the
workflow's peer.
- Schedules (cron + interval), namespaces, deployments (worker
versioning), nexus endpoints, identities, visibility query parser,
per-tenant store scoping, SSE event stream, replication scaffolding
(`replication/` quasar + local), migration tool.
Persistence = per-`(principal, namespace)` SQLite shard
(`pkg/tasks/store`), WAL, single-writer, `kv` + `history` + `idem` +
`meta` tables. Survives restart (`TestStore_PersistsAcrossOpen`).
A `Principal` is the tenant that owns a shard — an org, optionally
narrowed to a project. Shards and namespaces are created on first use;
nothing is declared up front.
### Storage and encryption — `hanzoai/cek` + `hanzoai/namespace`
Tasks no longer has a storage layout or a key schedule of its own. It
uses the estate's:
namespace names the tenant, and where its file lives
cek turns the process master + that name into the file's key,
and opens it
sqlite opens a file under a raw key, knowing nothing about who owns it
kms holds the master
<data>/orgs/<org>/<ns>.db
<data>/orgs/<org>/projects/<project>/<ns>.db
<data>/orgs/_platform/<ns>.db the root (unscoped) tenant
The key is DERIVED (HKDF of master + namespace + namespace-name), not
generated, wrapped or stored. So there is no DEK, no `.dek` sidecar, no
unwrap step, no rewrap step, no per-file key material to lose, and no
migration path to maintain. A shard is born encrypted or it does not
exist; losing the master loses the data, which is the property you want
from encryption at rest and the reason the master lives in KMS.
`EmbedConfig.MasterKey` (32 bytes, `KMS_MASTER_KEY` base64 for `tasksd`)
is installed into cek at `store.New`. A **nil master no longer means
plaintext** — there is no such posture. It means "I have no key to
state": embedded in a process that already installed one (cloud resolves
it through `credz` at boot) that key is used, so one deployment has one
key; otherwise a random master is minted for this process and nothing it
writes outlives the run, which is the honest shape of a keyless
deployment and why a developer needs no configuration.
A shard is ciphertext on every build: `hanzoai/sqlite` uses the live
libsqlcipher codec when one is linked and its pure-Go SQLCipher envelope
when one is not. On the envelope the file is written when it is SEALED
(checkpoint/close), not per commit — which is what the manager's sweep
bounds, and why `ListShards`/`ListPrincipals` read the resident set as
well as the directory: a shard created moments ago has no file yet, and
that is exactly the tenant the cron sweeper most needs to see.
**The org is folded to its storage identity once, at the door**
(`store.Org`, via the one injective slugger `namespace.Sanitize`), and
never again when that identity is turned into a place. `Principal.Org`
downstream is therefore the STORED name — which is what lets
`ListPrincipals` read a slug back off disk and reach the same file.
Folding twice would re-suffix it into a different, empty database, since
Sanitize is injective but deliberately not idempotent. A name Sanitize
refuses is kept raw so `Valid` rejects it, because folding it would yield
the empty org — and an empty org leg is the ROOT principal, so a name too
hostile to store would otherwise have become the deployment's own tenant.
### Known gap — no user-scoped shard
`Principal` still carries a `User` leg (it is the wire encoding, and
`ParsePrincipal`/`String` round-trip it), but a principal that narrows to
a user **cannot open a shard**: `Principal.Namespace` returns
`store.ErrUserLeg`. `namespace.Key` refuses a user namespace on purpose —
that layout has no place for one, "and inventing one silently is how a
second convention starts". The old `<org>/<project>/<user>/` tree was
tasks' own; sharing the estate's layout costs that depth until
`hanzoai/namespace` grows a user layout. Scope to the org or its project.
`tasksd upgrade` is **gone** with the layout it moved shards into. The
only subcommand is `migrate`, which moves ONE shard across NODES.
### What is genuinely missing (the real gap)
The whole system is explicitly **Phase 1: coroutine-push, not replay**
(see `pkg/sdk/worker/worker.go:19`, `workflow/version.go:31`,
`workflow/local_activity.go:62`). A workflow runs as ONE live worker
goroutine that blocks on activity results pushed back over the wire.
Consequences:
1. **No deterministic replay.** Workflow-driven activities (worker
`OpcodeScheduleActivity` 0x006B) write NOTHING to the *workflow's*
history — only START/terminal/signal/cancel land. The history cannot
reconstruct a run, so it cannot be replayed.
2. **No crash-recovery of workflows.** If the worker (or tasksd) dies
mid-run, the live goroutine is lost; nothing replays history to
resume. The dispatcher's pending/inflight state is in-memory only.
3. **No timers** (workflow `Sleep`/`NewTimer` has no durable server
side), **no activity retry/backoff** (RetryPolicy is stored, never
enforced), **no child workflows** (`OpcodeStartChildWorkflow` → 501).
### Target architecture — event-sourced durable execution
One model, the Temporal core done minimally. **History is the only source
of truth. A workflow task is one decision episode. The worker is
stateless between episodes — it rebuilds state by replaying history.**
The loop:
1. `ExecuteWorkflow` persists the run + `WORKFLOW_EXECUTION_STARTED`,
marks a **workflow task pending**, enqueues it.
2. Server delivers the workflow task **carrying the full history**
(`WorkflowTask.History` — the wire slot already exists; today it
smuggles raw input, `dispatch.go:431` "Phase 2 will parse an
event-sourced history").
3. Worker **replays** from event 0, re-running the registered function.
Each workflow primitive consults history by a **deterministic command
sequence number** (`seq`, incremented per command in program order):
- `ExecuteActivity` at `seq=k`: if `ActivityTaskCompleted{seq=k}` is
in history → return its recorded result (NO re-dispatch); if
`ActivityTaskFailed{seq=k}` (retries exhausted) → return the failure;
else emit `ScheduleActivity{seq=k}` and the future stays unresolved.
- `NewTimer`/`Sleep` at `seq=k`: `TimerFired{seq=k}` → resolved; else
emit `StartTimer{seq=k, fireAt}`.
- Signals: `WorkflowExecutionSignaled` events replay into the signal
channels in history order.
4. When the function can make no more progress (all pending futures
unresolved) the episode ends; the worker returns the batch of NEW
commands via `RespondWorkflowTaskCompleted` (the `commandsEnvelope`
already models `ScheduleActivity`; add `StartTimer`, `StartChild`,
plus the existing `CompleteWorkflow`/`FailWorkflow`).
5. Server **applies commands** — for each: append the corresponding
`*_SCHEDULED`/`*_STARTED` history event and act (dispatch activity,
arm timer, start child, terminal-transition). Command application is
idempotent on `(run, seq)`.
6. When an activity completes/fails, a timer fires, a signal or cancel
arrives, or a child completes → append the result event
(`ActivityTaskCompleted{seq}`, `TimerFired{seq}`, …) and **schedule a
new workflow task**. Back to step 2.
**Determinism invariant**: same history ⇒ same command sequence. Enforced
by (a) the workflow function being pure w.r.t. inputs + history,
(b) every non-deterministic input (activity result, timer fire, time,
version marker, side-effect) sourced from a history event keyed by `seq`,
(c) the decider dropping commands whose `seq` already has a
`*_SCHEDULED`/`*_STARTED` event (no double-schedule on replay).
**Exactly-once activity dispatch**: an activity is identified by
`(ns, run, seq)`. Scheduling is idempotent on that key — replay or a
recovery re-dispatch of the same `seq` returns the same activity record
and never runs the user activity twice once it is terminal. Server-side
retry: on `ActivityTaskFailed`, if `attempt < RetryPolicy.MaximumAttempts`
and the error is retryable, re-dispatch after `min(MaximumInterval,
InitialInterval · BackoffCoefficient^(attempt-1))`, recording each attempt;
on exhaustion write the terminal `ActivityTaskFailed{seq}` and schedule a
workflow task.
**Crash-recovery (single replica)**: everything above is in SQLite. On
tasksd boot, a recovery pass scans RUNNING workflows and rebuilds the
in-memory dispatcher from history: any workflow whose latest event needs
a decision (started, or a result landed with no following
`WorkflowTaskCompleted`) → re-enqueue a workflow task; any activity
`Scheduled`/`Started` with no terminal event → re-dispatch (idempotent per
`(run, seq)`); any `TimerStarted` with no `TimerFired` → re-arm to its
`fireAt`. Because dispatch is `seq`-keyed and the decider dedups on
replay, recovery causes no lost and no double execution.
**Multi-replica (later)**: the SQLite store already replicates via
`replication/` (quasar). Add a **per-(ns, run) dispatch lease** in the
replicated `meta` table so exactly one replica owns advancing a given
run at a time (mirror the exactly-once lease pattern used elsewhere in
the fleet). Until then tasksd runs single-writer (replicas=1).
### History event vocabulary (add to the existing family)
`WORKFLOW_TASK_SCHEDULED/STARTED/COMPLETED/FAILED`,
`ACTIVITY_TASK_SCHEDULED/STARTED/COMPLETED/FAILED/TIMED_OUT`,
`TIMER_STARTED/FIRED/CANCELED`,
`CHILD_WORKFLOW_EXECUTION_STARTED/COMPLETED/FAILED`. Each carries its
`seq` in `Attributes`. These are JSON-native, owned by Hanzo, and
persisted in the `history` table alongside the existing `kv` records.
### Phasing (what ships when)
- **Phase 2a (first coherent slice, this lane):** durable activity core.
Workflow-driven activity lifecycle written to the workflow's history;
exactly-once dispatch keyed by `(run, seq)`; server-side activity
retry+backoff per RetryPolicy; crash-recovery re-dispatch of in-flight
activities; history-backed `ExecuteActivity` replay (recorded result
returned on re-run) + workflow-task-driven advance. Tests (`-race`):
determinism/replay, retry+backoff timing, exactly-once dispatch,
crash-recovery (kill mid-activity → resume, no double-run).
- **Phase 2b:** durable timers (`StartTimer`/`TimerFired` + timer wheel),
signals/cancel folded into replay, child workflows (retire the 0x006D
501), query consistency against replayed state.
- **Phase 2c:** multi-replica dispatch lease over the replicated store.
### Scope discipline (agent orchestration, not Temporal parity)
Workflows = agent-session / subagent trees. Activities = agent runs +
tool calls. Signals = control commands (pause/resume/message). Timers =
schedules/retries. Child workflows = subagents. Skip search attributes v2,
advanced visibility, multi-namespace federation, Nexus cross-cluster —
unless trivial. Once `ExecuteWorkflow + child + signal + cancel` are real,
task #31 (agent sessions) wires the live TaskController in one line and
#32 (converge agent-scheduler + vm-metering tickers onto durable cron)
unblocks.
### KNOWN HAZARD — origin/main regression (coordination)
`origin/main` was force-pushed on 2026-07-02 to re-merge ~494 upstream
Temporal commits (`merge: upstream/main`, `pin go.temporal.io/server
v1.32.0-157.0`), re-adding `proto/`, `config/`, `develop/`, `docker/` and
the `go.temporal.io/*` deps — while the LLM.md still claims "runtime fully
ripped out." The native engine (`pkg/tasks`) is temporal-free and
identical across the divergence; the re-add is dependency/tooling baggage
the engine does not use. This contradicts the "temporal fork is GONE"
doctrine and must be resolved by a deliberate decision (re-rip vs. keep as
fork base) — do not let it silently become the base. `feat/tasks-durable-
engine` branches off this commit and keeps all new work inside the
temporal-free `pkg/tasks` + `pkg/sdk/workflow` zones so it rebases clean
whichever way that decision lands.