Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
b2126e5
feat(ai-harness): thread-state log records and their fold
AlemTuzlak Oct 17, 2026
a60085f
feat(ai-persistence, ai-harness): one persistence shape, every host h…
AlemTuzlak Oct 17, 2026
869b4a5
Merge branch 'feat/harness-lifecycle' into feat/harness-log-session
AlemTuzlak Oct 17, 2026
c4cafd3
feat(ai-harness): settings, config, plugin state, interrupted turns, …
AlemTuzlak Oct 17, 2026
96ce736
Merge branch 'feat/harness-lifecycle' into feat/harness-log-session
AlemTuzlak Oct 17, 2026
1a0962a
feat(ai-harness): one ownership epoch per log, renewed once per host,…
AlemTuzlak Oct 17, 2026
babeff6
feat(ai-harness): runs and interrupts live in the session log
AlemTuzlak Oct 17, 2026
07470f5
feat(ai-persistence, ai-harness): inputs from any machine through the…
AlemTuzlak Oct 17, 2026
0430d43
feat(ai-harness): createHarnessWorker, session.schedule, and step.sle…
AlemTuzlak Oct 17, 2026
c249782
test(e2e): a worker on another host runs a submitted input and a sche…
AlemTuzlak Oct 17, 2026
8e9797c
feat(ai-harness): one log per session tree, session.logState(), and a…
AlemTuzlak Oct 17, 2026
6d96156
feat(ai-harness): snapshots in the log store by size or time, retenti…
AlemTuzlak Oct 17, 2026
1d2300f
fix(ai-harness): export LogOwnedElsewhereError, and say that the log …
AlemTuzlak Oct 17, 2026
28b0800
docs(ai-harness, ai-persistence): durable sessions, one log per tree,…
AlemTuzlak Oct 17, 2026
a28f10c
ci: apply automated fixes
autofix-ci[bot] Oct 17, 2026
606fa8e
fix(ai-harness): check answers and cancels from submit and schedule a…
AlemTuzlak Oct 17, 2026
1dc6224
test(ai-harness): give polled checks 5 s, for slower CI coverage runs
AlemTuzlak Oct 17, 2026
f75cced
Merge remote-tracking branch 'origin/feat/harness-log-session' into f…
AlemTuzlak Oct 17, 2026
4c3d101
test(e2e): read the data lines of POST run streams that now carry an …
AlemTuzlak Oct 17, 2026
d73bc86
Merge branch 'feat/harness-lifecycle' into feat/harness-log-session
AlemTuzlak Oct 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions .changeset/ai-harness-core.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,21 @@ Add `@tanstack/ai-harness`: build an agent harness on TanStack AI. A harness kee

- `defineHarness()` names an agent: its adapter, tools, system prompts, middleware, agents, subagents, routing, plugins, turn hooks, and durability options.
- `createHarnessHost()` opens sessions. Each `host.open` gives its own handle to the live session of the thread. `session.close()`, or `await using`, releases the handle. A dropped handle is released by the garbage collector, with one warning. A call on a handle after its session closed opens the session again.
- `lifecycle` on `createHarnessHost()` sets how sessions live. A session that no handle holds closes after it was idle for `idleCloseMs` (default 60 000). `maxOpen` (default 500) first closes the least recently used idle sessions. `retention` (`settledOperations`, `settledAgentRuns`, `eventBytes`) bounds the settled operations, agent runs, receipts, events, and log tail that each session keeps in memory. `host.close({ recoverable })` stops the work of every session and refuses later opens.
- `host.observe(harness, { threadId, principal })` reads a thread with no session: its events, transcript, snapshot, media, usage, and entry. It sets up no plugins, writes nothing, and claims nothing.
- `host.sessions.delete` closes the sessions of a thread and of its child threads. Then it deletes their logs or messages, index entries, uploaded media, generated artifacts, settings, config, interrupted turn, usage, and revert data.
- `lifecycle` on `createHarnessHost()` sets how sessions live. A session that no handle holds closes after it was idle for `idleCloseMs` (default 60 000). `maxOpen` (default 500) first closes the least recently used idle sessions. `retention` (`settledOperations`, `settledAgentRuns`, `eventBytes`) bounds the settled operations, agent runs, receipts, events, and log tail that each session keeps in memory. `host.close({ recoverable })` stops the work of every session and refuses later opens. With `recoverable: true`, the host gives each log that has work back with a wake, so a worker takes it at once.
- `host.observe(harness, { threadId, principal })` reads a thread with no session: its events, transcript, snapshot, media, usage, and entry. It sets up no plugins, writes nothing, and never takes the log, so a read works while another host owns the thread. `view.logState()` reads the `durability.reduce` fold.
- `host.sessions.delete` closes the sessions of a thread and of its child threads. Then it deletes the log of the session tree (with the settings, config, plugin state, interrupted turn, runs, interrupts, usage, revert data, and the inputs that wait), the index entries, the uploaded media, the generated artifacts, and the log checkpoint.
- A session takes `send` (a prompt, a steer into the running turn, a follow-up, or a message to an agent run with `to`), `continue`, `reset`, `resolve`, `cancel`, and `background` inputs. Each returns an operation with a receipt, and the session streams typed events. `host.isOpen(harness, { threadId })` says whether this host holds that session open.
- `createHarnessHost({ access })` decides who can use each session. `identify({ request })` reads the user of a request, and `authorize({ principal, action, session, run })` decides each action. The principal type comes from what `identify` returns. The host keeps each tenant apart, needs a `principal` on each call, and `host.open` returns a handle that acts only as its principal. A refused action throws `AccessDeniedError`. Without `access`, one user runs the host, with no checks. With `access`, keyed adapters and provider keys read env vars only when `access.envKeys` is `true`.
- `membersOnly()` is a ready `authorize` with the roles owner, member, and viewer. `host.sessions.share` and `host.sessions.unshare` change the members, and `host.sessions.fork` takes `members: 'self' | 'all'`. For apps with their own access tables, `host.revoke` takes the access of a user to a thread. `host.identify` reads the user of a request for your own routes. Each `host.sessions` method takes one object.
- A principal with a `tenantId` keeps its sessions in its tenant: one thread id in two tenants is two sessions, with two index entries and two logs. `logIdOf({ tenantId, logId })` gives the id of a log in the store.
- Every run has a run context with its user, and that user's credentials and provider keys: `ctx.context.run` in tools and middleware (type them with `HarnessRunContext`), `ctx.turn.run` in a turn plugin, and `run` in a command. The `ctx.credentials` and `ctx.keys` of a session plugin have no user, so each call throws. `ctx.agents.start(agent, input, { principal })` starts an agent for a user. The log keeps the sender of every input.
- Every run has a run context with its user, and that user's credentials and provider keys: `ctx.context.run` in tools and middleware (type them with `HarnessRunContext`), `ctx.turn.run` in a turn plugin, and `run` in a command. The `ctx.credentials` and `ctx.keys` of a session plugin have no user, so each call throws. `ctx.agents.start(agent, input, { principal })` starts an agent for a user. The log keeps the sender of every input. The run context also has `epoch`, the ownership epoch of the log, and `idempotencyKey(step, scope?)`, a key for one side effect that is the same when the run runs again on any host.
- `definePlugin()` adds tools, prompts, middleware, agents, commands, config options, state, and extension points to a session or to one turn. With `lifetime: 'host'`, the host sets a plugin up once per harness and shares it with every session. It is cleaned up when the last session that uses it closes, or at `host.close()`.
- Agents run from code with `session.agents.<name>.run()` and `.start()`. In agent code, `ctx.harness` gives `step.do()` (a finished step returns its stored value when the run runs again) and `agents.start()` (a child that counts against the subagent budget).
- With `stores.log` from `@tanstack/ai-persistence`, a session is durable: inputs, tool results, and steps are in the log, and the next host recovers a turn that a crash stopped. `durableTool()` runs side effects once. `durability.truncatedToolResult` sets the text of a tool call cut at the output limit. Without it, a cut answer ends the turn.
- Agents run from code with `session.agents.<name>.run()` and `.start()`. In agent code, `ctx.harness` gives `step.do()` (a finished step returns its stored value when the run runs again), `step.sleep({ name, ms })`, and `agents.start()` (a child that counts against the subagent budget).
- Every host keeps each session tree in one log, `stores.log` from `@tanstack/ai-persistence`. `memoryPersistence()` has a memory log, and a host with no `persistence` uses it. The log holds the inputs, events, transcripts, runs, interrupts, settings, config, plugin state, agent results, schedules, and tool steps, and the host folds them when a thread opens. A root thread and its child threads (agent runs and subagents) write to the log of the root. `session.logState()` reads the `durability.reduce` fold of the whole tree. With a log store that survives a restart, the next host recovers a turn that a crash stopped.
- One host owns a log at a time: the first write takes it, one timer per host renews it, and a host that loses it stops its work on it. A read never takes the log. An input that reaches another host waits in the input lane of the log, and the owner runs it (receipt `queued`). `ownership: { ttlMs, renewMs }` sets the timing.
- The owner writes a snapshot of the folded log to the log store after `log.snapshot.afterBytes` of new records (default 1 000 000) or `log.snapshot.afterMs` (default 300 000), and a new host opens from it. With `lifecycle.retention.logRecords: 'since-snapshot'` (default `'all'`), the owner truncates the records before each snapshot. A reader whose cursor is in the dropped part gets one `harness.gap` event (`EventGap`), then the stream goes on. With `'since-snapshot'`, a thread whose snapshot the host cannot use (a changed `reduce` version or `project`) cannot open, and rejects with `LogTruncatedError`.
- `host.submit` stores an input for a thread and runs nothing. `session.schedule({ at, input, scheduleId, principal })` runs an input later, and `session.cancelSchedule({ scheduleId })` stops it. `createHarnessWorker({ host, harnesses, concurrency, signal, pollMs })` runs the threads that have work and no live host, and returns `{ stop }`. `host.resumePending` does one sweep.
- `durableTool()` runs side effects once. Its `step.do` passes `{ idempotencyKey, epoch }` to `fn`. `step.sleep({ name, ms })` waits in the process, and the log keeps the wake time, so a worker continues the call after a stop. `durability.truncatedToolResult` sets the text of a tool call cut at the output limit. Without it, a cut answer ends the turn.
- `turn.onModelError`, `retryTransientErrors()`, `turn.beforeFinish`, and `turn.canJoin` control how a turn retries, continues, and ends.
- `session.onQuestion`, `session.onAuthRequired`, `session.onMedia`, and `session.onUsage` call a listener for the events that a user answers or sees, with a typed value.
- Sessions also have thread settings, fork and revert, usage totals, media files, the transcript of each child agent (`session.agentTranscript`), and `harnessText()` / `harnessAgent()` to use a harness as a text adapter or an agent.
2 changes: 1 addition & 1 deletion .changeset/harness-forward-inputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
'@tanstack/ai-harness': patch
---

On a durable host with `stores.workClaims`, an input that reaches a host while another host owns the thread no longer takes the log from the running turn. With `stores.inbox`, the input waits in the inbox and the owner applies it (receipt `queued`). Without an inbox, it is refused with `owned_elsewhere`. A question from `ctx.session.ask` keeps its thread claimed, so an answer that reaches another host goes to the host that asked.
An input that reaches a host while another host owns the thread waits in the input lane of the log, and the owner runs it (receipt `queued`). The running turn on the owner goes on. The input keeps its JSON overrides (`model`, `modelOptions`, `reasoning`, and `promptCache`). An answer to a question from `ctx.session.ask` goes to the host that asked, and the log keeps a secret answer as `[secret]`.
4 changes: 2 additions & 2 deletions .changeset/harness-join-any-instance.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
'@tanstack/ai-harness': patch
---

On a durable host (`stores.log` and `stores.runs`), any server instance can serve a run.
Any server instance that reads the same log store can serve a run.

- `POST run` sends the log cursor of each event as its SSE `id`. A client that is cut off sends the same request with `Last-Event-ID`, and any instance continues the stream from there. The turn does not run again.
- `GET run?runId=` finds a turn that another instance runs. It streams the turn from the shared log, from its start or from `Last-Event-ID`, until the turn ends.
- `GET run?runId=&threadId=` finds a turn that another instance runs. It streams the turn from the log of `threadId`, from its start or from `Last-Event-ID`, until the turn ends. Without `threadId`, only the instance that runs the turn finds it, and another instance answers `400`. The join request of a `ChatClient` sends only the run id, so put `threadId` in the URL of the connection.
- `GET run?threadId=` shows a turn that another instance runs as `activeRun`, so a reloaded `useChat` joins it.
2 changes: 1 addition & 1 deletion .changeset/harness-recover-live-owner.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
'@tanstack/ai-harness': patch
---

A durable host that opens a thread while another host holds its work claim no longer recovers that host's inputs, also an input that has no run lease yet. The crash sweep recovers them after the claim expires.
One host owns the log of a thread at a time. The first write of a host takes the log, one timer per host renews every log that it owns, and the host gives a log back when it has no work. A host that opens a thread while another live host owns its log does not recover that work: the work runs again only after that ownership expires. A host that loses a log stops its work on it, and its sessions still read the thread. The `ownership` option of `createHarnessHost` sets `ttlMs` and `renewMs`.
2 changes: 1 addition & 1 deletion .changeset/harness-secure-defaults.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@
Make the transport defaults safe for a server with many instances.

- `createHarnessHandler` and `handleHarnessSocket` read the user of each request with the host `access`. On a host without `access`, pass `openAccess: true` (no user) or `openAccess: { principal }` (one local user), else they throw at the start. So a server never serves every thread to everyone by mistake.
- On a durable host (`stores.log`), `createHarnessHandler` throws without `media: { secret }`, so a media URL that one instance signs works on every instance. A signed URL names its user, and the handler checks that user again when it serves the file.
- `media: { secret }` signs the media URLs of `createHarnessHandler`. Set the same secret on every instance, so a media URL that one instance signs works on every instance. Without it, the handler signs with a random key of its own, and a URL works only on that instance. A signed URL names its user, and the handler checks that user again when it serves the file.
- `oauthConnector` needs `login`. Use `'device'` on a server, and `'loopback'` only for a harness on the user's own machine, such as a CLI.
2 changes: 1 addition & 1 deletion .changeset/harness-tool-ops.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Run a harness tool with no model turn, add host records from a client, and add a
- `expose.records` lists the host record types a client may add with an
`{ op: 'append', records }` input, or with the new `client.append(records)`.
A record of another type, or with a `thread` field, is refused with
`not_exposed`. The host must be durable (with `stores.log`).
`not_exposed`. The session log keeps the records.
- `createHarnessMcpServer` adds a `tool_<name>` tool for each tool in
`expose.tools`, and an `append` tool when `expose.records` has types. A tool
input with its own `threadId` field now throws when the server is created,
Expand Down
2 changes: 1 addition & 1 deletion .changeset/harness-view-experimental.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
'@tanstack/ai-harness': patch
---

Mark `createSessionView`, `SessionViewState`, and `SessionViewEvents` as `@experimental`. Document that event cursors are opaque strings: log positions on a durable host, and process-local counters without a log store.
Mark `createSessionView`, `SessionViewState`, and `SessionViewEvents` as `@experimental`. Document that event cursors are opaque strings: positions in the session log.
7 changes: 2 additions & 5 deletions .changeset/persistence-session-stores.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,10 @@

Add opt-in session stores. The chat and generation middleware work as before without them.

- `inbox` (`InboxStore`): the inputs a host accepted, with an idempotent `append` by `inputId`, `listPending`, `markApplied`, `markRejected`, and `get`.
- `log` (`LogStore`): an append-only session log with 14 methods, each with one object argument. Records: `append`, `read`, and `subscribe`. Ownership: `acquire`, `renew`, `release`, `setAffinity`, and `listOrphaned`. The input lane: `offer` and `offered`, consumed in the same write with `append({ consumes })`. Snapshots and lifecycle: `getSnapshot`, `putSnapshot`, `truncate`, and `deleteLog`. Each fenced write carries the epoch from `acquire`, and a write with an old epoch rejects with `OwnershipLostError`. A writer that never calls `acquire` uses `UNOWNED_EPOCH`. `append` rejects with `LogConflictError` when another writer took the position, and `read` rejects with `LogTruncatedError` before the truncation point. `memoryLogStore()` is the reference store. It is not part of `memoryPersistence()`. `memoryLogBackend()` lets two store instances share one log. The conformance kit needs `twin`, a second store set on the same backend, for the `log` cases across instances.
- `leases` (`LeaseStore`, `TurnLease`, `TurnLeaseKey`): turn leases from a system that already has them. `defineLeaseStore()` types a custom one, `memoryLeaseStore()` is the reference store, and the conformance testkit covers it. It is not part of `memoryPersistence()`.
- `workClaims` (`WorkClaimStore`): claims on threads that have pending work, with `claim`, `release`, and `listExpired`.
- `log` (`LogStore`): an append-only log for each session tree, with 14 methods, each with one object argument. Records: `append`, `read`, and `subscribe`. Ownership: `acquire`, `renew`, `release`, `setAffinity`, and `listOrphaned`. The input lane: `offer` and `offered`, consumed in the same write with `append({ consumes })`. Snapshots and lifecycle: `getSnapshot`, `putSnapshot`, `truncate`, and `deleteLog`. Each fenced write carries the epoch from `acquire`, and a write with an old epoch rejects with `OwnershipLostError`. A writer that never calls `acquire` uses `UNOWNED_EPOCH`. `append` rejects with `LogConflictError` when another writer took the position, and `read` rejects with `LogTruncatedError` before the truncation point. `memoryLogStore()` is the reference store. `memoryLogBackend()` lets two store instances share one log. The conformance kit needs `twin`, a second store set on the same backend, for the `log` cases across instances.
- `sessions` (`SessionIndexStore`): one index entry per tenant and thread. The key is `{ tenantId?, threadId }` (`SessionIndexKey`), so one thread id in two tenants is two entries. An entry has an `ownerId` and `members` (`SessionMember`, with the role `owner`, `member`, or `viewer`). The store has `get(key)`, `getVersioned(key)`, `upsert(entry)`, `upsertIf({ entry, revision })` for compare-and-set writes, `list`, and `delete(key)`, a hard delete. `list` reads one tenant, filters by member, parent thread, title `search`, `harness` name, and exact `metadata` values, and pages with a cursor.

`defineInboxStore`, `defineLogStore`, `defineWorkClaimStore`, and `defineSessionIndexStore` type an adapter. `memoryPersistence()` now also has `inbox`, `workClaims`, and `sessions`. `runPersistenceConformance` runs the cases of each session store when the store is present, so an existing backend needs no `skip` entry.
`defineLogStore` and `defineSessionIndexStore` type an adapter. `memoryPersistence()` now also has `log` and `sessions`. `runPersistenceConformance` runs the cases of each session store when the store is present, so an existing backend needs no `skip` entry.

With `stores.sessions`, `withPersistence` writes one index entry for each subagent child (`subagent:<runId>`), in the tenant of its `tenantId` option. The entry has the parent thread, the tool call, and the `ownerId` and `harness` name of the parent entry. It has no members: access to a child goes through its top-level thread.

Expand Down
2 changes: 1 addition & 1 deletion .changeset/persistence-store-shapes.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
'@tanstack/ai-persistence': patch
---

`InboxStore.get`, `markApplied`, and `markRejected` take the thread id first, so a store can key an entry by thread and input id. `InboxStore.delete` removes an entry: a harness host deletes a forwarded answer after it applies it, so a secret answer does not stay in the inbox. `WorkClaimStore.get(threadId)` returns the owner of a claim and when it expires. The session store docs allow a content-hash revision (an ETag of S3 or Netlify Blobs) that comes back for the same entry.
The session store docs allow a content-hash revision (an ETag of S3 or Netlify Blobs) that comes back for the same entry.
Loading
Loading