deleteSession cascades, and SubSessionRef.location
This guide covers the release in which SessionStateStore.deleteSession(parent) deletes the parent's whole child tree (RM-64), and every sub-session ref records where its child runs. It is a minor release of @helix-agents/core, @helix-agents/store-memory, @helix-agents/store-redis, @helix-agents/store-postgres, @helix-agents/store-cloudflare, @helix-agents/runtime-js, @helix-agents/runtime-temporal and @helix-agents/runtime-dbos, @helix-agents/ai-sdk and @helix-agents/agent-server (in the runtimes, the ref writers set location and write a remote child's ref before starting it; runtime-temporal also drops its unused RegisterSubSessionsInputSchema export), and a major release of @helix-agents/runtime-cloudflare (a package at 1.0 or later takes a major for a breaking change: here the typed Durable Object refusal and its migration). In the 0.x packages a minor marks an observable behaviour change, so read this guide before upgrading.
SubSessionRef.location is required (breaking)
SubSessionRef gains a required field, location: 'local' | 'remote'. The writer sets it when it creates the ref and never changes it: 'local' for a sub-agent or companion that runs in this service, 'remote' for a child that runs in another service behind a remote agent tool. The type is SubSessionRefLocation, with the Zod schemas SubSessionRefLocationSchema and SubSessionRefSchema exported from @helix-agents/core.
Every built-in ref writer (core companion and remote dispatch, runtime-js, Cloudflare Workflows, Temporal, DBOS) and every built-in store already handle it. You change code only in these two cases.
Custom SessionStateStore implementations
Persist the field, return it from getSubSessionRefs, and validate refs on the way in with assertValidSubSessionRefs. A ref with no location is a writer bug: the helper throws a non-retryable validation_error before anything is written. Read the stored value strictly (parse it with SubSessionRefLocationSchema); do not default a missing value to 'local', because that would let the cascade delete a remote child.
import {
assertValidSubSessionRefs,
SubSessionRefLocationSchema,
type SubSessionRef,
} from '@helix-agents/core';
class MyStore implements SessionStateStore {
async addSubSessionRefs(sessionId: string, refs: SubSessionRef[]): Promise<void> {
assertValidSubSessionRefs(sessionId, refs); // throws validation_error on a missing location
for (const ref of refs) {
await this.db.insertRef({ sessionId, ...ref, location: ref.location }); // store the column
}
}
async getSubSessionRefs(sessionId: string): Promise<SubSessionRef[]> {
const rows = await this.db.selectRefs(sessionId);
return rows.map((row) => ({
subSessionId: row.sub_session_id,
// ...the other fields...
location: SubSessionRefLocationSchema.parse(row.location), // strict, no default
}));
}
}addSubSessionRefs is insert-if-absent: when a ref with the same (sessionId, subSessionId) is already stored, or repeats an earlier ref in the same call, the store skips it and keeps the stored ref unchanged (its location, status, remote and every other field). A retried remote call can re-add its ref, and an overwrite would turn a completed remote ref back into a running one without its stream identity. Change a stored ref with updateSubSessionRef. A SQL store gets this from ON CONFLICT DO NOTHING / INSERT OR IGNORE on the (session_id, sub_session_id) key; a key-value store must check and write in one atomic step. The contract case "re-adding an existing ref keeps the stored one" checks it.
A custom store must also implement the cascade (next section) and run the contract suite, which fails a store that drops or defaults location:
stateStoreContractTests({
name: 'MyStore',
createStore: async () => ({ store: new MyStore() }),
// skip: { deleteSession: 'why this store has no deleteSession' }, // printed in each skipped case
// rawHooks: { usage: { insertRow, countRows } }, // a store that owns a usage table
// rawHooks: { sessionResidue }, // what the store still holds for a session, discovered from its schema
});rawHooks.sessionResidue returns every place (table, key) your store still holds data for a session, discovered from the store's own schema or keyspace. With it, the cascade's residue case fails a store that leaves anything behind for a deleted session, including a table added after this release.
Direct addSubSessionRefs callers
Code that calls addSubSessionRefs itself (a custom runtime, a test helper, a migration script) now passes location. Before:
await store.addSubSessionRefs(parentId, [
{
subSessionId: childId,
agentType: 'researcher',
parentToolCallId: callId,
status: 'running',
startedAt: Date.now(),
mode: 'ephemeral',
},
]);After:
await store.addSubSessionRefs(parentId, [
{
subSessionId: childId,
agentType: 'researcher',
parentToolCallId: callId,
status: 'running',
startedAt: Date.now(),
mode: 'ephemeral',
location: 'local', // 'remote' for a child that runs in another service
},
]);The optional remote stream metadata is not the marker. A remote ref created before its stream metadata arrives is still location: 'remote'.
deleteSession cascades
deleteSession(sessionId) now deletes the session and every descendant, with all their data (state, messages, runs, checkpoints, sub-session refs, staging, interrupt flags and abandoned run starts). A session's children are:
- the sessions named by its local sub-session refs, and
- the sessions whose
parentSessionIdis that session,
minus any session it names through a remote ref. The walk is recursive and cycle-safe. It starts from sessionId even when that session is already gone, so calling deleteSession again finishes a delete that did not finish.
Remote children are never deleted. A child behind a location: 'remote' ref belongs to another service, even when it shares the store. Every runtime writes that ref before it starts the remote child, and a failed write fails the call, so a remote child never exists without its marker. The call first reads the parent's refs (a retried call finds its ref already there and keeps it); a failed read also fails the call (a retryable transport_error) before anything is written or started. The exclusion lives on the parent's refs: a deleteSession repeated after the parent was fully deleted (for example after a lost reply) follows parentSessionId alone and deletes a remote child that shares the store and names the parent (FU-DELETE-SESSION-REMOTE-CHILD-AFTER-PARENT-GONE).
Before, deleteSession left the child sessions behind. A reused parent session id (execute, deleteSession, execute) whose model repeated a tool-call id then reused the old child session, and the child's model saw the deleted conversation. Now the children start fresh on runtime-js, the Cloudflare Workflows runtime and Temporal. What to check: code that relied on a child session outliving its parent's delete must read the child first.
Companion re-spawn. Re-spawning a failed or terminated persistent companion deletes its old session (and so its tree) first. A failure of that delete now fails the spawn, with the companion's ref left terminal, instead of re-spawning onto the old session; only the Durable Object store's typed framework_not_supported refusal is tolerated. On D1, as on Postgres, the delete also removes the old session's usage rows.
Deleting a tree that has a live run is undefined, as before: a child created while the delete runs may survive it. Wait for the run to end, or interrupt it, before you delete.
Per store, the delete is: memory, synchronous; Redis, a walk first and then a delete of the deepest sessions first and the root last (each session's hash, index entries and refs together, after its other keys); Postgres, one transaction; D1, one read of the tree and then one atomic batch. See Deleting a session.
A missing session or checkpoint is 404
Core's HTTP status table (httpStatusForHelixError, behind every typedErrorResponse) gains a not-found row group: state_session_not_found, state_checkpoint_not_found and state_checkpoint_foreign answer 404 on every host, ahead of the state category's 409. Before, the CF Durable Object's /start / /resume / /retry and handleChatStream answered all three 409, and agent-server's /start / /resume answered the checkpoint codes 409. Every /snapshot route, agent-server's stop routes and its /resume pre-check (404 NOT_FOUND) already answered a missing session 404; those answers are unchanged.
A foreign checkpoint (a checkpointId that names another session's checkpoint) is answered over HTTP exactly as a missing one: 404 { error: "Checkpoint '<id>' not found for session <sessionId>.", code: 'state_checkpoint_not_found' }, so a client cannot learn that the id exists elsewhere. The foreign detail (both session ids) is logged at warn (http.checkpoint_foreign_answered_not_found) through the route's logger. In-process, an executor's resume() / retry() / branch still throws state_checkpoint_foreign (core CheckpointForeignError, with checkpointId, sessionId and ownerSessionId).
A route's own catch answers through chatErrorResponse(error, { sessionId, logger }) (@helix-agents/ai-sdk): pass your server logger, so a foreign checkpoint that reaches it is logged the same way.
The DO's remote-protocol routes (/subagent/:agentType/start|resume) answer a missing or foreign checkpoint 404 INVALID_REQUEST with errorCode: 'state_checkpoint_not_found' (was 409 INTERNAL_ERROR); NOT_FOUND stays the unknown session or agent type, so DOStubTransport keeps throwing RemoteAgentNotFoundError only for those.
What to check: a client that branched on 409 for these codes. Classify on code, not on the status.
D1 deletes usage rows
deleteSession on the D1 store now also deletes the __agents_usage rows of every deleted session, as the Postgres store already did. The memory and Redis state stores keep usage in a separate UsageStore that they cannot reach, so their deleteSession leaves usage rows in place (tracked as FU-USAGE-STORE-DELETE-SESSION).
Redis: the parent index follows parentSessionId
The Redis store now moves a session's idx:sessions:parent:<parent> entry whenever a state write changes parentSessionId. Before, a child created without a parent and saved with one later (the Temporal shape) never reached its parent's index, so listSessions({ parentSessionId }) missed it. It now appears there and in the cascade.
DO-SQLite refuses deleteSession
The Durable Object state store (DOStateStore) does not support deleting a session: each session is its own Durable Object, with its children in sibling objects, so a cascade could not be atomic. It already threw; it now throws a typed HelixError with code: 'framework_not_supported' (non-retryable) instead of a plain Error. Catch it on code, not on the message text:
import { HelixError } from '@helix-agents/core';
try {
await doStateStore.deleteSession(sessionId);
} catch (error) {
if (error instanceof HelixError && error.code === 'framework_not_supported') {
// delete through the owning Durable Object instead, or leave the session
} else {
throw error;
}
}Migrations
Run the migration for each store you use before you roll the new code. All are forward-only.
| Store | Migration |
|---|---|
D1 (store-cloudflare) | V21: a partial index on __agents_states(parent_session_id) and __agents_sub_session_refs.location |
| Postgres | V17: __agents_sub_session_refs.location ('local' or 'remote') |
| DO-SQLite | 15: sub_session_refs.location |
| Redis, memory | none (a new hash field and an in-memory field) |
Apply them with runMigration() (D1), PostgresMigrations.run() (Postgres), or let the Durable Object migrate itself when it boots. D1 runs them through the shared claim-first runner, so a concurrent boot is safe; the Durable Object applies its own DO_MIGRATIONS with its in-object runPendingMigrations on first access (single-threaded per object, so there is no concurrent boot).
DBOS divergence
On DBOS the store cascade holds, but the children do not start fresh after a delete and re-execute. DBOS derives a child's workflow id from the child session id alone and treats it as an idempotency key, so a re-executed parent gets the old recorded child result whatever the store holds. This applies to both a sub-agent and a companion. Do not rely on delete-then-re-execute to reset the children on DBOS. It is tracked as FU-DBOS-CHILD-WORKFLOW-ID-REUSE (conformance finding RM-75), and the e2e file cross-runtime-delete-session-cascade-dbos.integ.test.ts pins the divergence.
A DBOS handle kept across deleteSession (the persistent-mode handle, or a getHandle() reconnect handle) still reports result() as status: 'failed' for the deleted session, now with errorDetail.code: 'state_session_not_found' (category state, not retryable; before, it carried no errorDetail).
Cloudflare Workflows handles follow the same rule, child sessions removed by the cascade included: the instance's recorded outcome when it has one, else a typed state_session_not_found.