This is an automated email from the ASF dual-hosted git repository.

jason810496 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 8533cc40b0a TS SDK: add the task state store to the task client 
(#73618)
8533cc40b0a is described below

commit 8533cc40b0af312facfc8f467b786a48e6c8373a
Author: Wilson Josix Wang <[email protected]>
AuthorDate: Fri Oct 9 18:13:24 2026 +0900

    TS SDK: add the task state store to the task client (#73618)
    
    * TS SDK: add the task state store to the task client
    
    TypeScript task handlers had no way to reach the per-task-instance state
    store that Python exposes as context["task_state_store"], so a retried
    TypeScript task could not reattach to an external job it had already
    submitted. The supervisor and the generated wire types already carried
    the four state-store messages; only the client surface was missing.
    
    The store is reached through client.taskStateStore, with get, set,
    delete and clear, so it reads like the Python accessor and the Java and
    Go stores rather than four more flat client methods. It is confined to
    the caller's own task instance by the Execution API, so the task
    instance id is bound when the coordinator client is built rather than
    exposed on TaskContext. Absence handling becomes operation-specific
    instead of one shared not-found set, because a fourth lookup makes the
    shared set answer null for codes an operation can never receive.
    
    Expiry is resolved on the runtime, as Python does: omitting retentionMs
    follows AIRFLOW__STATE_STORE__DEFAULT_RETENTION_DAYS, which the
    coordinator passes from [state_store] default_retention_days, and the
    write fails when that is absent or malformed rather than retaining the
    key for a period nobody configured. NEVER_EXPIRE is a symbol, so a
    retention that reaches Infinity by accident is rejected instead of
    silently storing a key forever.
    
    * TS SDK: cover the task state store in the example bundle and e2e tests
    
    The example bundle is what the ts_sdk e2e mode runs, so the new client
    methods need handlers there before the capability can be claimed. The
    e2e assertions read the store back through the public REST API rather
    than the task's own XCom, so they prove the values reached the database
    and that NEVER_EXPIRE arrives as a null expiry. Clearing lives in its
    own task because clear removes every key of the task instance that
    calls it, which would destroy the rows the round-trip test asserts on.
    
    * TS SDK: document the task state store and mark the capability supported
    
    With the client methods in place and exercised end to end, the
    compatibility matrix can stop listing task-state-store as unsupported.
    The guide spells out the two places TypeScript deliberately differs from
    Python so deployment managers are not surprised: the worker-side
    state_store_backend is not used, and a config value of 0 means never
    expire while retentionMs: 0 expires the key immediately.
    
    * Fix spelling in TS SDK task state store docs
    
    
    ---------
    
    Co-authored-by: ZHE YOU LIU <[email protected]>
---
 .../language-sdks/typescript.rst                   |  53 ++++-
 .../airflow_e2e_tests/e2e_test_utils/clients.py    |  14 +-
 .../ts_sdk_tests/test_ts_sdk_dag.py                |  65 +++++-
 ts-sdk/README.md                                   |  15 +-
 ts-sdk/api-docs/dag-authoring-api.ts               |  11 +-
 ts-sdk/api-docs/supporting-types.ts                |   8 +-
 ts-sdk/capabilities.yaml                           |   5 +-
 ts-sdk/example/dags/typescript_example.py          |  10 +
 ts-sdk/example/src/main.ts                         |  36 +++-
 ts-sdk/src/coordinator/client.ts                   | 198 ++++++++++++++---
 ts-sdk/src/coordinator/protocol.ts                 |   5 +
 ts-sdk/src/coordinator/runtime.ts                  |   4 +-
 ts-sdk/src/index.ts                                |  11 +-
 ts-sdk/src/sdk/client-types.ts                     |  23 ++
 ts-sdk/src/sdk/client.ts                           |  74 ++++++-
 ts-sdk/tests/coordinator/client.test.ts            | 238 ++++++++++++++++++++-
 ts-sdk/tests/coordinator/integration.test.ts       |  23 ++
 ts-sdk/tests/public-api.test.ts                    |  22 ++
 18 files changed, 759 insertions(+), 56 deletions(-)

diff --git 
a/airflow-core/docs/authoring-and-scheduling/language-sdks/typescript.rst 
b/airflow-core/docs/authoring-and-scheduling/language-sdks/typescript.rst
index d2c6094a105..0cb37f578e2 100644
--- a/airflow-core/docs/authoring-and-scheduling/language-sdks/typescript.rst
+++ b/airflow-core/docs/authoring-and-scheduling/language-sdks/typescript.rst
@@ -496,7 +496,7 @@ Two functions give it what it needs while it runs:
        that fires when Airflow stops the task. Pass ``signal`` to ``fetch()``, 
timers, or any other API that
        accepts one, so the task can stop cleanly.
    * - ``getClient()``
-     - A ``TaskClient`` for Airflow Variables, Connections and XComs.
+     - A ``TaskClient`` for Airflow Variables, Connections, XComs and the task 
state store.
 
 Both work anywhere inside the handler's call, including helper functions and 
code after an ``await``, and
 throw outside it. Await everything the handler starts: work still running 
after the handler returns runs
@@ -523,6 +523,8 @@ The ``TaskClient``
   ``taskId`` and ``mapIndex`` default to the current task; pass ``taskId`` to 
read an upstream task's XCom.
   See :ref:`typescript-sdk/types` for how values map to JavaScript types.
 * ``setXCom({ key, value, ... })`` publishes an XCom value.
+* ``taskStateStore`` is this task instance's key/value store, with 
``get<T>(key)``,
+  ``set(key, value, { retentionMs? })``, ``delete(key)`` and ``clear()``.
 
 .. note::
 
@@ -530,6 +532,55 @@ The ``TaskClient``
    stored value when the Variable is read back. ``setVariable`` without a 
description clears the existing
    description, and ``deleteVariable`` succeeds even when the Variable does 
not exist.
 
+Task state store
+----------------
+
+The task state store is a per-task-instance key/value store, scoped to 
``dagId``, ``runId``, ``taskId``,
+and ``mapIndex`` (but not ``tryNumber``), so a value written by one attempt is 
still readable by the next.
+It survives worker crashes and task retries within the same Dag run, which 
makes it a good place to record
+external job IDs, checkpoints within a task, and progress metadata. See 
:doc:`/core-concepts/task-state-store`
+for the full concept, including the equivalent Python API.
+
+.. code-block:: typescript
+
+    import { getClient, NEVER_EXPIRE } from "apache-airflow-ts-sdk";
+
+    export async function runSparkJob() {
+      const store = getClient().taskStateStore;
+
+      let jobId = await store.get<string>("job_id");
+      if (jobId == null) {
+        jobId = await submitSparkJob();
+        await store.set("job_id", jobId, { retentionMs: NEVER_EXPIRE });
+      }
+
+      const result = await waitForSparkJob(jobId);
+      await store.delete("job_id");
+      return result;
+    }
+
+**Retention.** ``retentionMs`` is milliseconds to retain the key, counted from 
the time of the write:
+
+* A number retains the key for that many milliseconds from now; ``retentionMs: 
0`` expires the key
+  immediately.
+* ``NEVER_EXPIRE`` (imported from ``apache-airflow-ts-sdk``) stores the key 
with no expiry, regardless of
+  the deployment default.
+* When ``retentionMs`` is omitted the runtime reads 
``AIRFLOW__STATE_STORE__DEFAULT_RETENTION_DAYS``, which
+  the coordinator sets from ``[state_store] default_retention_days``. A value 
of ``0`` there means the key
+  never expires. This is not the same as ``retentionMs: 0`` above, which 
expires the key immediately.
+
+**Keys** must be non-empty strings of at most 512 characters; they may contain 
slashes. **Values** must be
+JSON-compatible (see :ref:`typescript-sdk/types`), and can never be ``null`` — 
``set`` rejects a ``null``
+value. ``get`` returns ``null`` only to mean "key not found."
+
+Two behaviors to note:
+
+* ``[workers] state_store_backend`` is not used from TypeScript tasks, so 
values are always stored inline
+  through the Execution API. A key written by a Python task through a custom 
worker-side backend reads back
+  from TypeScript as that backend's reference marker string, not the original 
value.
+* ``[state_store] clear_on_success`` removes all of a task instance's state 
store keys when the task
+  succeeds, the same as for Python tasks.
+
 Logging
 -------
 
diff --git 
a/airflow-e2e-tests/tests/airflow_e2e_tests/e2e_test_utils/clients.py 
b/airflow-e2e-tests/tests/airflow_e2e_tests/e2e_test_utils/clients.py
index 87b9b816637..273ab225320 100644
--- a/airflow-e2e-tests/tests/airflow_e2e_tests/e2e_test_utils/clients.py
+++ b/airflow-e2e-tests/tests/airflow_e2e_tests/e2e_test_utils/clients.py
@@ -163,12 +163,14 @@ class AirflowClient:
         """Get an Airflow Variable via API."""
         return self._make_request(method="GET", endpoint=f"variables/{key}")
 
-    def get_task_state_store(self, dag_id: str, run_id: str, task_id: str, 
key: str):
-        """Get a single task state store entry via API."""
-        return self._make_request(
-            method="GET",
-            
endpoint=f"dags/{dag_id}/dagRuns/{run_id}/taskInstances/{task_id}/state-store/{key}",
-        )
+    def get_task_state_store(
+        self, dag_id: str, run_id: str, task_id: str, key: str | None = None, 
map_index: int = -1
+    ):
+        """Get task state store entries, or a single key, for a task instance 
via API."""
+        endpoint = 
f"dags/{dag_id}/dagRuns/{run_id}/taskInstances/{task_id}/state-store"
+        if key is not None:
+            endpoint = f"{endpoint}/{key}"
+        return self._make_request(method="GET", 
endpoint=f"{endpoint}?map_index={map_index}")
 
     def trigger_dag_and_wait(self, dag_id: str, json=None):
         """Trigger a DAG and wait for it to complete."""
diff --git 
a/airflow-e2e-tests/tests/airflow_e2e_tests/ts_sdk_tests/test_ts_sdk_dag.py 
b/airflow-e2e-tests/tests/airflow_e2e_tests/ts_sdk_tests/test_ts_sdk_dag.py
index 5424e04dad7..57a20d683fd 100644
--- a/airflow-e2e-tests/tests/airflow_e2e_tests/ts_sdk_tests/test_ts_sdk_dag.py
+++ b/airflow-e2e-tests/tests/airflow_e2e_tests/ts_sdk_tests/test_ts_sdk_dag.py
@@ -25,7 +25,8 @@ Two Dags mix Python tasks with ``@task.stub`` TypeScript 
tasks, both served by t
 ``airflow-ts-pack`` bundle, and each is triggered once via a module-scoped 
fixture.
 
 ``typescript_example`` covers the runtime: Variable reads and writes, 
Connection reads,
-Python <-> TypeScript XCom round-trips, and task logs reaching the log store.
+Python <-> TypeScript XCom round-trips, task state store round-trips and 
clearing, and task
+logs reaching the log store.
 
 ``typescript_taskflow_example`` covers TaskFlow arguments, including an 
upstream output pulled before
 the handler runs and a ``withArgNames`` rename on its ``report`` task, and 
shares a ``build_message``
@@ -121,6 +122,8 @@ def test_task_states(completed_run: _CompletedRun):
         "build_message": "success",
         "read_connection": "success",
         "write_and_delete_variable": "success",
+        "write_and_read_task_state": "success",
+        "clear_task_state": "success",
     }
     for task_id, want in expected.items():
         assert completed_run.ti_states.get(task_id) == want, (
@@ -173,6 +176,66 @@ def 
test_scratch_variable_deleted_by_typescript_task_is_gone(completed_run: _Com
     )
 
 
+def test_task_state_store_round_trip(completed_run: _CompletedRun):
+    """``write_and_read_task_state`` writes two keys, reads them back, and 
deletes one."""
+    value = completed_run.xcom("write_and_read_task_state")
+    assert value["jobId"] == completed_run.run_id, (
+        f"jobId should be this run's id {completed_run.run_id!r}, got 
{value!r}"
+    )
+    assert value["scratchBeforeDelete"] == {"attempt": 1}, (
+        f"scratchBeforeDelete should be {{'attempt': 1}}, got {value!r}"
+    )
+    assert value["scratchAfterDelete"] is None, (
+        f"scratchAfterDelete should be None after taskStateStore.delete, got 
{value!r}"
+    )
+
+    body = completed_run.client.get_task_state_store(
+        dag_id=completed_run.dag_id, run_id=completed_run.run_id, 
task_id="write_and_read_task_state"
+    )
+    assert body["total_entries"] == 1, f"expected a single remaining task 
state store entry, got {body!r}"
+    assert {entry["key"] for entry in body["task_state_store"]} == 
{"typescript_example_job_id"}, (
+        f"expected only the job id key to remain, got {body!r}"
+    )
+
+    entry = completed_run.client.get_task_state_store(
+        dag_id=completed_run.dag_id,
+        run_id=completed_run.run_id,
+        task_id="write_and_read_task_state",
+        key="typescript_example_job_id",
+    )
+    assert entry["value"] == completed_run.run_id, (
+        f"job id entry should hold this run's id {completed_run.run_id!r}, got 
{entry!r}"
+    )
+    assert entry["expires_at"] is None, f"NEVER_EXPIRE should reach the 
database as no expiry, got {entry!r}"
+
+
+def test_task_state_store_clear(completed_run: _CompletedRun):
+    """``clear_task_state`` removes every task state store key it wrote."""
+    value = completed_run.xcom("clear_task_state")
+    assert value["afterClear"] is None, f"afterClear should be None after 
taskStateStore.clear, got {value!r}"
+
+    body = completed_run.client.get_task_state_store(
+        dag_id=completed_run.dag_id, run_id=completed_run.run_id, 
task_id="clear_task_state"
+    )
+    assert body["total_entries"] == 0, f"taskStateStore.clear should remove 
all entries, got {body!r}"
+    assert body["task_state_store"] == [], f"taskStateStore.clear should 
remove all entries, got {body!r}"
+
+
+def test_task_state_store_deleted_key_is_gone(completed_run: _CompletedRun):
+    """A key written and then deleted from TypeScript no longer exists via the 
REST API."""
+    with pytest.raises(requests.HTTPError) as excinfo:
+        completed_run.client.get_task_state_store(
+            dag_id=completed_run.dag_id,
+            run_id=completed_run.run_id,
+            task_id="write_and_read_task_state",
+            key="typescript_example_scratch",
+        )
+    assert excinfo.value.response.status_code == HTTPStatus.NOT_FOUND, (
+        f"typescript_example_scratch should have been deleted by the 
TypeScript task, "
+        f"got HTTP {excinfo.value.response.status_code}"
+    )
+
+
 def test_coordinator_logs_reach_task_log_store(completed_run: _CompletedRun):
     assert "[ts-sdk.runtime] Coordinator runtime started" in 
completed_run.logs("build_message")
 
diff --git a/ts-sdk/README.md b/ts-sdk/README.md
index 465f0ec5597..90e15609d74 100644
--- a/ts-sdk/README.md
+++ b/ts-sdk/README.md
@@ -179,12 +179,13 @@ both a Dag declared in TypeScript and the tasks of two 
Python Dags from one bund
 
 `getClient()` returns a `TaskClient` for task-time Airflow data access, for as 
long as a handler is running:
 
-| Method                                                          | 
Description             |
-| --------------------------------------------------------------- | 
----------------------- |
-| `getVariable(key)` / `getVariableOrThrow`                       | Airflow 
Variables       |
-| `setVariable(key, value, description?)` / `deleteVariable(key)` | Variable 
write / delete |
-| `getXCom(opts)` / `setXCom(opts)`                               | XCom 
read/write         |
-| `getConnection(connId)` / `getConnectionOrThrow`                | Airflow 
Connections     |
+| Method                                                          | 
Description                            |
+| --------------------------------------------------------------- | 
-------------------------------------- |
+| `getVariable(key)` / `getVariableOrThrow`                       | Airflow 
Variables                      |
+| `setVariable(key, value, description?)` / `deleteVariable(key)` | Variable 
write / delete                |
+| `getXCom(opts)` / `setXCom(opts)`                               | XCom 
read/write                        |
+| `getConnection(connId)` / `getConnectionOrThrow`                | Airflow 
Connections                    |
+| `taskStateStore.get / set / delete / clear`                     | 
Per-task-instance key/value state store |
 
 Locator fields such as `dagId`, `runId`, and `taskId` default to the
 current task context when omitted.
@@ -227,7 +228,7 @@ Do not edit the table by hand. Update the manifest and run 
the `update-ts-sdk-re
 | capability: `variable-read-write` | MUST | ✓ | 3.4 | getVariable / 
setVariable / deleteVariable |
 | capability: `self-contained-bundle` | MUST | ✓ | 3.4 | Airflow metadata 
embedded in the bundle |
 | capability: `retry-policy` | MAY | ✗ | – | no task-facing retry-policy API 
yet |
-| capability: `task-state-store` | MAY | ✗ | – | no task-facing state-store 
API yet |
+| capability: `task-state-store` | MAY | ✓ | 3.4 | taskStateStore.get / set / 
delete / clear |
 | capability: `asset-state-store` | MAY | ✗ | – | no task-facing state-store 
API yet |
 | capability: `asset-event-emit` | MAY | ✗ | – | runtime does not emit asset 
events yet |
 | capability: `asset-event-read` | MAY | ✗ | – | no task-facing asset-event 
API yet |
diff --git a/ts-sdk/api-docs/dag-authoring-api.ts 
b/ts-sdk/api-docs/dag-authoring-api.ts
index 79b8ddf5e72..a6147025a30 100644
--- a/ts-sdk/api-docs/dag-authoring-api.ts
+++ b/ts-sdk/api-docs/dag-authoring-api.ts
@@ -19,7 +19,15 @@
 
 /** @module Authoring */
 
-export { Bundle, Dag, getClient, getContext, TaskHandler, withArgNames } from 
"../src/index.js";
+export {
+  Bundle,
+  Dag,
+  getClient,
+  getContext,
+  NEVER_EXPIRE,
+  TaskHandler,
+  withArgNames,
+} from "../src/index.js";
 export type {
   ArgNameMap,
   DagSpec,
@@ -31,4 +39,5 @@ export type {
   TaskOptions,
   TaskRef,
   TaskSpec,
+  TaskStateStore,
 } from "../src/index.js";
diff --git a/ts-sdk/api-docs/supporting-types.ts 
b/ts-sdk/api-docs/supporting-types.ts
index f928752d91d..3f5dc929e6b 100644
--- a/ts-sdk/api-docs/supporting-types.ts
+++ b/ts-sdk/api-docs/supporting-types.ts
@@ -19,4 +19,10 @@
 
 /** @module Types */
 
-export type { ConnectionResult, GetXComOpts, JsonValue, SetXComOpts } from 
"../src/index.js";
+export type {
+  ConnectionResult,
+  GetXComOpts,
+  JsonValue,
+  SetXComOpts,
+  TaskStateStoreSetOpts,
+} from "../src/index.js";
diff --git a/ts-sdk/capabilities.yaml b/ts-sdk/capabilities.yaml
index 170be9385cb..04f94159f82 100644
--- a/ts-sdk/capabilities.yaml
+++ b/ts-sdk/capabilities.yaml
@@ -86,8 +86,9 @@ capabilities:
     supported: false
     note: "no task-facing retry-policy API yet"
   task-state-store:
-    supported: false
-    note: "no task-facing state-store API yet"
+    supported: true
+    since: "3.4"
+    note: "taskStateStore.get / set / delete / clear"
   asset-state-store:
     supported: false
     note: "no task-facing state-store API yet"
diff --git a/ts-sdk/example/dags/typescript_example.py 
b/ts-sdk/example/dags/typescript_example.py
index 67718ec4bcf..13bc5c34d3d 100644
--- a/ts-sdk/example/dags/typescript_example.py
+++ b/ts-sdk/example/dags/typescript_example.py
@@ -37,12 +37,22 @@ def read_connection(): ...
 def write_and_delete_variable(): ...
 
 
[email protected](queue="typescript")
+def write_and_read_task_state(): ...
+
+
[email protected](queue="typescript")
+def clear_task_state(): ...
+
+
 @dag(dag_id="typescript_example", schedule=None, catchup=False, 
tags=["typescript", "example"])
 def typescript_example():
     start = python_start()
     message = build_message()
     read_connection()
     write_and_delete_variable()
+    write_and_read_task_state()
+    clear_task_state()
 
     start >> message
 
diff --git a/ts-sdk/example/src/main.ts b/ts-sdk/example/src/main.ts
index fa97d3f9899..d39c10b1c0a 100644
--- a/ts-sdk/example/src/main.ts
+++ b/ts-sdk/example/src/main.ts
@@ -22,7 +22,7 @@
 // Both Dags in `dags/` are declared in Python with `@task.stub` tasks routed 
to the Node
 // coordinator, so this side only supplies the task bodies.
 
-import { Bundle, getClient, getContext, TaskHandler } from 
"apache-airflow-ts-sdk";
+import { Bundle, getClient, getContext, NEVER_EXPIRE, TaskHandler } from 
"apache-airflow-ts-sdk";
 
 import { buildSummaryMessage, report, summarize } from "./taskflow.js";
 
@@ -57,6 +57,38 @@ export async function writeAndDeleteVariable() {
   await client.deleteVariable(SCRATCH_VARIABLE);
 }
 
+const JOB_ID_KEY = "typescript_example_job_id";
+const STATE_SCRATCH_KEY = "typescript_example_scratch";
+
+export async function writeAndReadTaskState() {
+  const store = getClient().taskStateStore;
+  const { runId, tryNumber } = getContext();
+
+  await store.set(JOB_ID_KEY, runId, { retentionMs: NEVER_EXPIRE });
+  await store.set(STATE_SCRATCH_KEY, { attempt: tryNumber });
+
+  const jobId = await store.get<string>(JOB_ID_KEY);
+  const scratchBeforeDelete = await store.get(STATE_SCRATCH_KEY);
+
+  await store.delete(STATE_SCRATCH_KEY);
+  const scratchAfterDelete = await store.get(STATE_SCRATCH_KEY);
+
+  return { jobId, scratchBeforeDelete, scratchAfterDelete };
+}
+
+export async function clearTaskState() {
+  const store = getClient().taskStateStore;
+
+  await store.set(JOB_ID_KEY, "placeholder");
+  await store.set(STATE_SCRATCH_KEY, { attempt: 1 });
+
+  await store.clear();
+
+  const afterClear = await store.get(JOB_ID_KEY);
+
+  return { afterClear };
+}
+
 export async function readConnection() {
   const connection = await 
getClient().getConnection("typescript_example_http");
 
@@ -77,6 +109,8 @@ bundle.register(
   new TaskHandler("typescript_example", "build_message", buildMessage),
   new TaskHandler("typescript_example", "read_connection", readConnection),
   new TaskHandler("typescript_example", "write_and_delete_variable", 
writeAndDeleteVariable),
+  new TaskHandler("typescript_example", "write_and_read_task_state", 
writeAndReadTaskState),
+  new TaskHandler("typescript_example", "clear_task_state", clearTaskState),
   new TaskHandler("typescript_taskflow_example", "summarize", summarize),
   new TaskHandler("typescript_taskflow_example", "report", report),
   new TaskHandler("typescript_taskflow_example", "build_message", 
buildSummaryMessage),
diff --git a/ts-sdk/src/coordinator/client.ts b/ts-sdk/src/coordinator/client.ts
index 8f47955dcdf..eaa49c687b1 100644
--- a/ts-sdk/src/coordinator/client.ts
+++ b/ts-sdk/src/coordinator/client.ts
@@ -20,8 +20,15 @@
 import type { CommChannel } from "./comm-channel.js";
 import type { LogChannel } from "./log-channel.js";
 import type { TaskContext } from "../sdk/task.js";
-import type { TaskClient } from "../sdk/client.js";
-import type { ConnectionResult, GetXComOpts, JsonValue, SetXComOpts } from 
"../sdk/client-types.js";
+import type { TaskClient, TaskStateStore } from "../sdk/client.js";
+import type {
+  ConnectionResult,
+  GetXComOpts,
+  JsonValue,
+  SetXComOpts,
+  TaskStateStoreSetOpts,
+} from "../sdk/client-types.js";
+import { NEVER_EXPIRE } from "../sdk/client-types.js";
 import { ConnectionNotFoundError, VariableNotFoundError } from 
"../sdk/client.js";
 import type {
   GetVariable,
@@ -34,12 +41,21 @@ import type {
   TriggerDagRun,
   GetDagRunState,
   GetDag,
+  ClearTaskStateStore,
+  DeleteTaskStateStore,
+  GetTaskStateStore,
+  SetTaskStateStore,
   ConnectionResult as WireConnectionResult,
 } from "./protocol.js";
 
-/** What a supervisor "row is absent" error means for an operation: only a 
lookup
- *  can return `null`; for a `void` call a swallowed error would read as 
success. */
-type AbsentRowPolicy = "null" | "throw";
+/**
+ * What a supervisor "row is absent" error means for an operation.
+ *
+ * `"throw"` always throws (a swallowed error on a `void` call would read as
+ * success); otherwise, the `ErrorType` codes that mean "absent" plus whether
+ * a wrapped API server 404 also counts (see `isAbsent`).
+ */
+type AbsentRowPolicy = "throw" | { codes: readonly string[]; apiServer404: 
boolean };
 
 function resolveWireMapIndex(
   requestedMapIndex: number | null | undefined,
@@ -101,9 +117,91 @@ export interface CoordinatorClient extends TaskClient {
   isDagPaused(dagId: string): Promise<boolean>;
 }
 
+const VARIABLE_ABSENT_POLICY: AbsentRowPolicy = {
+  codes: ["VARIABLE_NOT_FOUND"],
+  apiServer404: true,
+};
+const XCOM_ABSENT_POLICY: AbsentRowPolicy = { codes: ["XCOM_NOT_FOUND"], 
apiServer404: true };
+const CONNECTION_ABSENT_POLICY: AbsentRowPolicy = {
+  codes: ["CONNECTION_NOT_FOUND"],
+  apiServer404: true,
+};
+const TASK_STATE_STORE_ABSENT_POLICY: AbsentRowPolicy = {
+  codes: ["TASK_STORE_NOT_FOUND"],
+  apiServer404: false,
+};
+
+// A language SDK runtime cannot read Airflow config, so the coordinator passes
+// `[state_store] default_retention_days` at launch
+// (task-sdk/src/airflow/sdk/coordinators/_subprocess.py).
+const RETENTION_DAYS_ENV_VAR = "AIRFLOW__STATE_STORE__DEFAULT_RETENTION_DAYS";
+const MS_PER_DAY = 24 * 60 * 60 * 1000;
+
+function assertKey(key: string): void {
+  if (typeof key !== "string") {
+    throw new TypeError(`task state store key must be a string, got ${typeof 
key}`);
+  }
+  if (key === "") {
+    throw new RangeError("task state store key must not be empty");
+  }
+}
+
+// Python's `datetime` (and the wire format it parses) cannot represent a year
+// past 9999, and the supervisor silently drops a frame it cannot decode
+// rather than replying with an error, so the task would hang instead of
+// failing fast. Route both retention paths through this check.
+function toExpiresAt(ms: number): string {
+  const at = new Date(Date.now() + ms);
+  if (Number.isNaN(at.getTime()) || at.getUTCFullYear() > 9999) {
+    throw new RangeError(
+      `retention of ${ms}ms overflows the wire timestamp; use NEVER_EXPIRE 
instead`,
+    );
+  }
+  return at.toISOString();
+}
+
+/** Resolve `retentionMs` (or the deployment default) to a wire `expires_at`. 
*/
+function resolveExpiresAt(retentionMs: number | typeof NEVER_EXPIRE | 
undefined): string | null {
+  if (retentionMs === undefined) {
+    return resolveDefaultExpiresAt();
+  }
+  if (retentionMs === NEVER_EXPIRE) {
+    return null;
+  }
+  if (!Number.isFinite(retentionMs) || retentionMs < 0) {
+    throw new RangeError(`retentionMs must be >= 0 or NEVER_EXPIRE, got 
${String(retentionMs)}`);
+  }
+  return toExpiresAt(retentionMs);
+}
+
+// The coordinator always passes the setting, so an absent or malformed value 
is
+// a misconfiguration and fails the write rather than silently retaining the 
key
+// for a period nobody configured.
+function resolveDefaultExpiresAt(): string | null {
+  const raw = process.env[RETENTION_DAYS_ENV_VAR];
+  if (raw === undefined) {
+    throw new Error(
+      `${RETENTION_DAYS_ENV_VAR} is not set, so the default retention is 
unknown. It carries ` +
+        "[state_store] default_retention_days to the runtime; pass retentionMs 
or NEVER_EXPIRE to " +
+        "set the expiry explicitly.",
+    );
+  }
+  const days = Number(raw);
+  if (raw.trim() === "" || !Number.isInteger(days)) {
+    throw new RangeError(`[state_store] default_retention_days must be a whole 
number, got ${raw}`);
+  }
+  if (days < 0) {
+    throw new RangeError(
+      `[state_store] default_retention_days must be >= 0, got ${raw}. Set to 0 
to disable expiry.`,
+    );
+  }
+  return days === 0 ? null : toExpiresAt(days * MS_PER_DAY);
+}
+
 export function createCoordinatorClient(
   comm: CommChannel,
   ctx: TaskContext,
+  tiId: string,
   logs: LogChannel | null = null,
 ): CoordinatorClient {
   async function rpc<T>(
@@ -122,7 +220,7 @@ export function createCoordinatorClient(
         logs?.debug(`${op} answered ${err.code}`);
         return null;
       }
-      if (absent === "null" && isNotFound(err)) {
+      if (absent !== "throw" && isAbsent(err, absent)) {
         logs?.debug(`${op} not found`, { error: err.code });
         return null;
       }
@@ -141,6 +239,57 @@ export function createCoordinatorClient(
     return extract(body);
   }
 
+  const taskStateStore: TaskStateStore = {
+    async get<T = unknown>(key: string): Promise<T | null> {
+      assertKey(key);
+      const msg: GetTaskStateStore = { type: "GetTaskStateStore", ti_id: tiId, 
key };
+      return rpc(
+        "GetTaskStateStore",
+        "TaskStateStoreResult",
+        msg,
+        (body) => body!.value as T,
+        TASK_STATE_STORE_ABSENT_POLICY,
+      );
+    },
+
+    async set(
+      key: string,
+      value: NonNullable<JsonValue>,
+      opts: TaskStateStoreSetOpts = {},
+    ): Promise<void> {
+      assertKey(key);
+      if (
+        value === null ||
+        value === undefined ||
+        (typeof value === "number" && !Number.isFinite(value))
+      ) {
+        throw new TypeError("task state store value must not be null, NaN, or 
Infinity");
+      }
+      // TODO: warn when the serialized value exceeds the deployment's
+      // [state_store] max_value_storage_bytes, as Python's task store setter
+      // does, once the coordinator passes that setting to the runtime.
+      const msg: SetTaskStateStore = {
+        type: "SetTaskStateStore",
+        ti_id: tiId,
+        key,
+        value,
+        expires_at: resolveExpiresAt(opts.retentionMs),
+      };
+      await rpc("SetTaskStateStore", "OKResponse", msg, () => undefined, 
"throw");
+    },
+
+    async delete(key: string): Promise<void> {
+      assertKey(key);
+      const msg: DeleteTaskStateStore = { type: "DeleteTaskStateStore", ti_id: 
tiId, key };
+      await rpc("DeleteTaskStateStore", "OKResponse", msg, () => undefined, 
"throw");
+    },
+
+    async clear(): Promise<void> {
+      const msg: ClearTaskStateStore = { type: "ClearTaskStateStore", ti_id: 
tiId };
+      await rpc("ClearTaskStateStore", "OKResponse", msg, () => undefined, 
"throw");
+    },
+  };
+
   const client: CoordinatorClient = {
     // ---- Variables ----
 
@@ -151,7 +300,7 @@ export function createCoordinatorClient(
         "VariableResult",
         msg,
         (body) => (body!.value as string) ?? null,
-        "null",
+        VARIABLE_ABSENT_POLICY,
       );
     },
 
@@ -200,7 +349,7 @@ export function createCoordinatorClient(
           // result frame decides `found` rather than the value.
           value: (body!.value ?? null) as JsonValue,
         }),
-        "null",
+        XCOM_ABSENT_POLICY,
       );
       // `rpc` answers null for the supervisor's XCOM_NOT_FOUND.
       return entry ?? XCOM_ABSENT;
@@ -271,6 +420,10 @@ export function createCoordinatorClient(
       return paused!;
     },
 
+    // ---- Task state store ----
+
+    taskStateStore,
+
     // ---- Connections ----
 
     async getConnection(connId: string): Promise<ConnectionResult | null> {
@@ -280,7 +433,7 @@ export function createCoordinatorClient(
         "ConnectionResult",
         msg,
         (body) => fromWireConnection(body as unknown as WireConnectionResult),
-        "null",
+        CONNECTION_ABSENT_POLICY,
       );
     },
 
@@ -296,8 +449,8 @@ export function createCoordinatorClient(
 // -------- Error handling (two functions) --------
 //
 // parseFrameError: extract a structured error from the frame (once).
-// isNotFound: decide if the error means "absent" (a lookup returns null)
-//             or "failed" (throw).
+// isAbsent: decide, per operation policy, if the error means "absent" (a
+//           lookup returns null) or "failed" (throw).
 
 interface FrameError {
   code: string;
@@ -327,18 +480,13 @@ function parseFrameError(frame: { body: unknown; error?: 
unknown }): FrameError
   return null;
 }
 
-// Exact supervisor ErrorType codes that mean "absent", not "failed".
-const NOT_FOUND_CODES = new Set(["VARIABLE_NOT_FOUND", "XCOM_NOT_FOUND", 
"CONNECTION_NOT_FOUND"]);
-
-/** Is this error a "not found" (caller should get null, not a throw)?
- *  Covers both dedicated NOT_FOUND codes and API_SERVER_ERROR with 404. */
-function isNotFound(err: FrameError): boolean {
-  if (NOT_FOUND_CODES.has(err.code)) return true;
-  // The supervisor wraps API server 404s as API_SERVER_ERROR with
-  // detail.status_code=404 (supervisor.py: WatchedSubprocess.handle_requests).
-  // Dag / Dag run lookups hit this path.
-  // TODO: If the TS client adds lookups beyond variables, XCom, and 
connections,
-  // make not-found handling operation-specific instead of treating every
-  // API_SERVER_ERROR 404 as null.
-  return err.code === "API_SERVER_ERROR" && err.statusCode === 404;
+/** Is this error "absent" for `policy` (caller should get null, not a throw)?
+ *  The supervisor wraps API server 404s as API_SERVER_ERROR with
+ *  detail.status_code=404 (supervisor.py: WatchedSubprocess.handle_requests). 
*/
+function isAbsent(
+  err: FrameError,
+  policy: { codes: readonly string[]; apiServer404: boolean },
+): boolean {
+  if (policy.codes.includes(err.code)) return true;
+  return policy.apiServer404 && err.code === "API_SERVER_ERROR" && 
err.statusCode === 404;
 }
diff --git a/ts-sdk/src/coordinator/protocol.ts 
b/ts-sdk/src/coordinator/protocol.ts
index 9f2f97b949c..3db41259fe7 100644
--- a/ts-sdk/src/coordinator/protocol.ts
+++ b/ts-sdk/src/coordinator/protocol.ts
@@ -72,6 +72,11 @@ export type {
   DagRunStateResult,
   GetDag,
   DagResult,
+  ClearTaskStateStore,
+  DeleteTaskStateStore,
+  GetTaskStateStore,
+  SetTaskStateStore,
+  TaskStateStoreResult,
 } from "../generated/supervisor.js";
 
 // -------- Frames from supervisor --------
diff --git a/ts-sdk/src/coordinator/runtime.ts 
b/ts-sdk/src/coordinator/runtime.ts
index 8c7227f8e70..9e4a2c3211e 100644
--- a/ts-sdk/src/coordinator/runtime.ts
+++ b/ts-sdk/src/coordinator/runtime.ts
@@ -347,7 +347,7 @@ async function handleTask(
   const trigger = getBundleTrigger(bundle, ti.dag_id, ti.task_id);
   if (trigger) {
     const ctx = buildContext(details, signal);
-    const client = createCoordinatorClient(comm, ctx, clientLogs);
+    const client = createCoordinatorClient(comm, ctx, ti.id, clientLogs);
     const fail = (message: string) => {
       logs.error("Task failed", { task_id: ctx.taskId, error: message });
       return buildFailureResponse(details, message);
@@ -377,7 +377,7 @@ async function handleTask(
   }
 
   const ctx = buildContext(details, signal);
-  const client = createCoordinatorClient(comm, ctx, clientLogs);
+  const client = createCoordinatorClient(comm, ctx, ti.id, clientLogs);
 
   let bound: BoundArgs;
   try {
diff --git a/ts-sdk/src/index.ts b/ts-sdk/src/index.ts
index f71bbbf9ac0..4c629bda76a 100644
--- a/ts-sdk/src/index.ts
+++ b/ts-sdk/src/index.ts
@@ -24,6 +24,7 @@ export { TaskHandler } from "./sdk/task-handler.js";
 export { withArgNames } from "./sdk/arg-names.js";
 export { getClient, getContext } from "./sdk/task.js";
 export { ConnectionNotFoundError, VariableNotFoundError } from 
"./sdk/client.js";
+export { NEVER_EXPIRE } from "./sdk/client-types.js";
 export { SUPERVISOR_API_VERSION } from "./coordinator/index.js";
 export type { ArgNameMap } from "./sdk/arg-names.js";
 export type { Registerable } from "./sdk/bundle.js";
@@ -44,6 +45,12 @@ export type {
   TaskRef,
   TaskSpec,
 } from "./sdk/dag.js";
-export type { TaskClient } from "./sdk/client.js";
-export type { ConnectionResult, GetXComOpts, JsonValue, SetXComOpts } from 
"./sdk/client-types.js";
+export type { TaskClient, TaskStateStore } from "./sdk/client.js";
+export type {
+  ConnectionResult,
+  GetXComOpts,
+  JsonValue,
+  SetXComOpts,
+  TaskStateStoreSetOpts,
+} from "./sdk/client-types.js";
 export type { TaskContext, TaskFunction } from "./sdk/task.js";
diff --git a/ts-sdk/src/sdk/client-types.ts b/ts-sdk/src/sdk/client-types.ts
index 969e432006d..37c3b7b0e42 100644
--- a/ts-sdk/src/sdk/client-types.ts
+++ b/ts-sdk/src/sdk/client-types.ts
@@ -50,6 +50,29 @@ export interface SetXComOpts {
   mapIndex?: number | null;
 }
 
+/**
+ * Pass as {@link TaskStateStoreSetOpts.retentionMs} to store a task state 
store
+ * key with no expiry, regardless of the deployment default.
+ *
+ * A symbol rather than a number, so a retention that reaches `Infinity` by
+ * accident (a division by zero, for example) is rejected instead of silently
+ * storing a key forever.
+ */
+export const NEVER_EXPIRE: unique symbol = 
Symbol("apache-airflow-ts-sdk/NEVER_EXPIRE");
+
+/** Options for a task state store write. */
+export interface TaskStateStoreSetOpts {
+  /**
+   * Milliseconds to retain the key, counted from the write; `0` expires it
+   * immediately.
+   *
+   * Omitting it follows the deployment's `[state_store] 
default_retention_days`,
+   * which the coordinator passes to the runtime. Pass {@link NEVER_EXPIRE} to
+   * store the key with no expiry regardless of that default.
+   */
+  retentionMs?: number | typeof NEVER_EXPIRE;
+}
+
 /** Airflow Connection details returned by a task client. */
 export interface ConnectionResult {
   id: string;
diff --git a/ts-sdk/src/sdk/client.ts b/ts-sdk/src/sdk/client.ts
index 0facdd9695c..6927fc47831 100644
--- a/ts-sdk/src/sdk/client.ts
+++ b/ts-sdk/src/sdk/client.ts
@@ -17,7 +17,13 @@
  * under the License.
  */
 
-import type { ConnectionResult, GetXComOpts, SetXComOpts } from 
"./client-types.js";
+import type {
+  ConnectionResult,
+  GetXComOpts,
+  JsonValue,
+  SetXComOpts,
+  TaskStateStoreSetOpts,
+} from "./client-types.js";
 
 /**
  * Client for reading and writing Airflow task-time data from a task handler.
@@ -88,6 +94,13 @@ export interface TaskClient {
    */
   setXCom(opts: SetXComOpts): Promise<void>;
 
+  /**
+   * Key/value state private to this task instance.
+   *
+   * See {@link TaskStateStore}.
+   */
+  readonly taskStateStore: TaskStateStore;
+
   /**
    * Look up an Airflow Connection by ID.
    *
@@ -109,6 +122,65 @@ export interface TaskClient {
   getConnectionOrThrow(connId: string): Promise<ConnectionResult>;
 }
 
+/**
+ * Key/value state private to one task instance, kept across its retries within
+ * the same Dag run.
+ *
+ * Reach it through {@link TaskClient.taskStateStore}:
+ *
+ * ```ts
+ * const store = getClient().taskStateStore;
+ * let jobId = await store.get<string>("job_id");
+ * if (jobId == null) {
+ *   jobId = await submit();
+ *   await store.set("job_id", jobId, { retentionMs: NEVER_EXPIRE });
+ * }
+ * ```
+ *
+ * Values are stored in the metadata database as-is; the `[workers]
+ * state_store_backend` that Python tasks can configure is not applied here.
+ */
+export interface TaskStateStore {
+  /**
+   * Look up a value.
+   *
+   * Returns `null` when the key is missing. Throws on any other error.
+   *
+   * The generic `T` lets callers narrow the return type when the shape is
+   * known.
+   *
+   * @throws `TypeError` when `key` is not a string.
+   * @throws `RangeError` when `key` is empty.
+   */
+  get<T = unknown>(key: string): Promise<T | null>;
+
+  /**
+   * Store a value, replacing any existing value for the key.
+   *
+   * @throws `TypeError` when `key` is not a string, or `value` is null,
+   * undefined, NaN, or Infinity.
+   * @throws `RangeError` when `key` is empty; when `retentionMs` is
+   * negative, NaN, or Infinity; when the deployment's
+   * `[state_store] default_retention_days` is invalid; or when the resolved
+   * expiry would overflow the wire timestamp (year 9999).
+   */
+  set(key: string, value: NonNullable<JsonValue>, opts?: 
TaskStateStoreSetOpts): Promise<void>;
+
+  /**
+   * Delete a key.
+   *
+   * Resolves even when the key does not exist — the Execution API's delete
+   * route is idempotent and does not report a missing key as an error.
+   *
+   * @throws `TypeError` when `key` is not a string.
+   * @throws `RangeError` when `key` is empty.
+   */
+  delete(key: string): Promise<void>;
+
+  /** Delete every key stored for this task instance. */
+  clear(): Promise<void>;
+}
+
 /** Error thrown by {@link TaskClient.getVariableOrThrow}. */
 export class VariableNotFoundError extends Error {
   constructor(public readonly key: string) {
diff --git a/ts-sdk/tests/coordinator/client.test.ts 
b/ts-sdk/tests/coordinator/client.test.ts
index cba7154b516..f5e864f6629 100644
--- a/ts-sdk/tests/coordinator/client.test.ts
+++ b/ts-sdk/tests/coordinator/client.test.ts
@@ -17,13 +17,16 @@
  * under the License.
  */
 
-import { describe, it, expect } from "vitest";
+import { afterEach, describe, it, expect, vi } from "vitest";
 import { ConnectionNotFoundError } from "../../src/sdk/client.js";
+import { NEVER_EXPIRE } from "../../src/sdk/client-types.js";
 import { createCoordinatorClient } from "../../src/coordinator/client.js";
 import type { CommChannel } from "../../src/coordinator/comm-channel.js";
 import type { TaskClient } from "../../src/sdk/client.js";
 import type { TaskContext } from "../../src/sdk/task.js";
 
+const RETENTION_ENV_VAR = "AIRFLOW__STATE_STORE__DEFAULT_RETENTION_DAYS";
+
 function fakeComm(frames: { body: unknown; error?: unknown }[]): CommChannel {
   let i = 0;
   return {
@@ -40,8 +43,10 @@ const FAKE_CTX: TaskContext = {
   signal: new AbortController().signal,
 };
 
+const FAKE_TI_ID = "01890f3e-0000-7000-8000-000000000001";
+
 function client(frames: { body: unknown; error?: unknown }[]) {
-  return createCoordinatorClient(fakeComm(frames), FAKE_CTX);
+  return createCoordinatorClient(fakeComm(frames), FAKE_CTX, FAKE_TI_ID);
 }
 
 /** Captures the request bodies sent, answering each one with `reply`. */
@@ -53,7 +58,7 @@ function recordingClient(reply: unknown = null) {
       return { body: reply };
     },
   } as unknown as CommChannel;
-  return { client: createCoordinatorClient(comm, FAKE_CTX), sent };
+  return { client: createCoordinatorClient(comm, FAKE_CTX, FAKE_TI_ID), sent };
 }
 
 describe("getVariable not-found contract", () => {
@@ -157,6 +162,17 @@ describe("writes do not read a supervisor 404 as absence", 
() => {
     ["setVariable", "PutVariable", (c: TaskClient) => c.setVariable("k", "v")],
     ["deleteVariable", "DeleteVariable", (c: TaskClient) => 
c.deleteVariable("k")],
     ["setXCom", "SetXCom", (c: TaskClient) => c.setXCom({ key: "k", value: 1 
})],
+    [
+      "taskStateStore.set",
+      "SetTaskStateStore",
+      (c: TaskClient) => c.taskStateStore.set("k", 1, { retentionMs: 60_000 }),
+    ],
+    [
+      "taskStateStore.delete",
+      "DeleteTaskStateStore",
+      (c: TaskClient) => c.taskStateStore.delete("k"),
+    ],
+    ["taskStateStore.clear", "ClearTaskStateStore", (c: TaskClient) => 
c.taskStateStore.clear()],
   ])("%s rejects", async (_name, op, call) => {
     const c = client([
       { body: null, error: { error: "API_SERVER_ERROR", detail: { status_code: 
404 } } },
@@ -208,7 +224,7 @@ describe("client is bound to TaskContext", () => {
         return { body: null };
       },
     } as unknown as CommChannel;
-    const c = createCoordinatorClient(recordingComm, FAKE_CTX);
+    const c = createCoordinatorClient(recordingComm, FAKE_CTX, FAKE_TI_ID);
 
     await c.setXCom({ key: "echo", value: 1 });
     await c.setXCom({
@@ -243,7 +259,7 @@ describe("client is bound to TaskContext", () => {
         return { body: { type: "XComResult", key: b.key, value: null } };
       },
     } as unknown as CommChannel;
-    const c = createCoordinatorClient(recordingComm, FAKE_CTX);
+    const c = createCoordinatorClient(recordingComm, FAKE_CTX, FAKE_TI_ID);
 
     await c.getXCom({ key: "k" });
 
@@ -272,7 +288,7 @@ describe("client is bound to TaskContext", () => {
     // ctx with a real map index, to prove -1 from opts wins over a
     // mapped ctx value (caller is explicitly asking "the non-mapped row").
     const mappedCtx: TaskContext = { ...FAKE_CTX, mapIndex: 3 };
-    const c = createCoordinatorClient(recordingComm, mappedCtx);
+    const c = createCoordinatorClient(recordingComm, mappedCtx, FAKE_TI_ID);
 
     await c.setXCom({ key: "k", value: 1, mapIndex: -1 });
     await c.setXCom({ key: "k", value: 2, mapIndex: null });
@@ -377,3 +393,213 @@ describe("getConnectionOrThrow", () => {
     await 
expect(c.getConnectionOrThrow("warehouse")).rejects.toThrow(/API_SERVER_ERROR/);
   });
 });
+
+describe("taskStateStore.get", () => {
+  it("sends GetTaskStateStore with the bound ti_id and returns the stored 
value", async () => {
+    const { client: c, sent } = recordingClient({ type: 
"TaskStateStoreResult", value: { n: 1 } });
+
+    await expect(c.taskStateStore.get("k")).resolves.toEqual({ n: 1 });
+
+    expect(sent[0]).toEqual({ type: "GetTaskStateStore", ti_id: FAKE_TI_ID, 
key: "k" });
+  });
+
+  it("returns null for the exact TASK_STORE_NOT_FOUND code", async () => {
+    const c = client([{ body: { type: "ErrorResponse", error: 
"TASK_STORE_NOT_FOUND" } }]);
+    expect(await c.taskStateStore.get("k")).toBeNull();
+  });
+
+  it("rejects for an API_SERVER_ERROR with status 404, which is not in its 
absence policy", async () => {
+    const c = client([
+      { body: null, error: { error: "API_SERVER_ERROR", detail: { status_code: 
404 } } },
+    ]);
+    await 
expect(c.taskStateStore.get("k")).rejects.toThrow(/API_SERVER_ERROR/);
+  });
+
+  it("rejects for VARIABLE_NOT_FOUND, which is not in its absence policy", 
async () => {
+    const c = client([{ body: { type: "ErrorResponse", error: 
"VARIABLE_NOT_FOUND" } }]);
+    await 
expect(c.taskStateStore.get("k")).rejects.toThrow(/VARIABLE_NOT_FOUND/);
+  });
+
+  it("rejects on an empty key without sending a request", async () => {
+    const { client: c, sent } = recordingClient();
+    await expect(c.taskStateStore.get("")).rejects.toThrow(RangeError);
+    expect(sent).toHaveLength(0);
+  });
+});
+
+describe("getVariable does not read TASK_STORE_NOT_FOUND as absence", () => {
+  it("rejects for TASK_STORE_NOT_FOUND, which is not in its absence policy", 
async () => {
+    const c = client([{ body: { type: "ErrorResponse", error: 
"TASK_STORE_NOT_FOUND" } }]);
+    await expect(c.getVariable("k")).rejects.toThrow(/TASK_STORE_NOT_FOUND/);
+  });
+});
+
+describe("taskStateStore.set retention", () => {
+  afterEach(() => {
+    vi.unstubAllEnvs();
+    vi.useRealTimers();
+    vi.restoreAllMocks();
+  });
+
+  it("sends expires_at null for NEVER_EXPIRE", async () => {
+    vi.useFakeTimers();
+    vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
+    const { client: c, sent } = recordingClient({ type: "OKResponse", ok: true 
});
+
+    await c.taskStateStore.set("k", 1, { retentionMs: NEVER_EXPIRE });
+
+    expect(sent[0]).toEqual({
+      type: "SetTaskStateStore",
+      ti_id: FAKE_TI_ID,
+      key: "k",
+      value: 1,
+      expires_at: null,
+    });
+  });
+
+  it("sends now + retentionMs for a finite retentionMs", async () => {
+    vi.useFakeTimers();
+    vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
+    const { client: c, sent } = recordingClient({ type: "OKResponse", ok: true 
});
+
+    await c.taskStateStore.set("k", 1, { retentionMs: 60_000 });
+
+    expect(sent[0]).toMatchObject({ expires_at: "2026-01-01T00:01:00.000Z" });
+  });
+
+  it("sends now (expire immediately) for retentionMs 0", async () => {
+    vi.useFakeTimers();
+    vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
+    const { client: c, sent } = recordingClient({ type: "OKResponse", ok: true 
});
+
+    await c.taskStateStore.set("k", 1, { retentionMs: 0 });
+
+    expect(sent[0]).toMatchObject({ expires_at: "2026-01-01T00:00:00.000Z" });
+  });
+
+  it.each([
+    ["0", "0", null],
+    ["7", "7", "2026-01-08T00:00:00.000Z"],
+    ["7.0", "7.0", "2026-01-08T00:00:00.000Z"],
+  ])("resolves the default_retention_days env var: %s", async (_label, raw, 
expected) => {
+    vi.useFakeTimers();
+    vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
+    vi.stubEnv(RETENTION_ENV_VAR, raw);
+    const { client: c, sent } = recordingClient({ type: "OKResponse", ok: true 
});
+
+    await c.taskStateStore.set("k", 1);
+
+    expect(sent[0]).toMatchObject({ expires_at: expected });
+  });
+
+  it.each([
+    ["-1", "-1"],
+    ["abc", "abc"],
+    ["fractional", "7.5"],
+    ["empty", ""],
+  ])("rejects an invalid default_retention_days env var: %s", async (_label, 
raw) => {
+    vi.stubEnv(RETENTION_ENV_VAR, raw);
+    const { client: c, sent } = recordingClient({ type: "OKResponse", ok: true 
});
+
+    await expect(c.taskStateStore.set("k", 1)).rejects.toThrow(RangeError);
+    expect(sent).toHaveLength(0);
+  });
+
+  it("rejects when the coordinator did not pass default_retention_days", async 
() => {
+    vi.stubEnv(RETENTION_ENV_VAR, undefined);
+    const { client: c, sent } = recordingClient({ type: "OKResponse", ok: true 
});
+
+    await expect(c.taskStateStore.set("k", 
1)).rejects.toThrow(RETENTION_ENV_VAR);
+    expect(sent).toHaveLength(0);
+  });
+});
+
+describe("taskStateStore.set guards", () => {
+  afterEach(() => {
+    vi.restoreAllMocks();
+  });
+
+  it.each([
+    ["null", null],
+    ["undefined", undefined],
+    ["NaN", Number.NaN],
+    ["Infinity", Number.POSITIVE_INFINITY],
+    ["-Infinity", Number.NEGATIVE_INFINITY],
+  ])("rejects a %s value without sending a request", async (_label, value) => {
+    const { client: c, sent } = recordingClient();
+    // @ts-expect-error exercising the runtime guard against an untyped caller.
+    await expect(c.taskStateStore.set("k", value)).rejects.toThrow(TypeError);
+    expect(sent).toHaveLength(0);
+  });
+
+  it.each([
+    ["negative", -1],
+    ["NaN", Number.NaN],
+  ])("rejects retentionMs=%s without sending a request", async (_label, 
retentionMs) => {
+    const { client: c, sent } = recordingClient();
+    await expect(c.taskStateStore.set("k", 1, { retentionMs 
})).rejects.toThrow(RangeError);
+    expect(sent).toHaveLength(0);
+  });
+
+  it("rejects an empty key without sending a request", async () => {
+    const { client: c, sent } = recordingClient();
+    await expect(c.taskStateStore.set("", 1, { retentionMs: 60_000 
})).rejects.toThrow(RangeError);
+    expect(sent).toHaveLength(0);
+  });
+});
+
+describe("taskStateStore.delete", () => {
+  it("sends DeleteTaskStateStore and resolves on the supervisor's OKResponse", 
async () => {
+    const { client: c, sent } = recordingClient({ type: "OKResponse", ok: true 
});
+
+    await expect(c.taskStateStore.delete("k")).resolves.toBeUndefined();
+
+    expect(sent[0]).toEqual({ type: "DeleteTaskStateStore", ti_id: FAKE_TI_ID, 
key: "k" });
+  });
+
+  it("rejects on an empty key without sending a request", async () => {
+    const { client: c, sent } = recordingClient();
+    await expect(c.taskStateStore.delete("")).rejects.toThrow(RangeError);
+    expect(sent).toHaveLength(0);
+  });
+});
+
+describe("task state store key type guard", () => {
+  it.each([
+    [
+      "get",
+      (c: TaskClient) => {
+        // @ts-expect-error exercising the runtime guard against an untyped 
caller.
+        return c.taskStateStore.get(undefined);
+      },
+    ],
+    [
+      "set",
+      (c: TaskClient) => {
+        // @ts-expect-error exercising the runtime guard against an untyped 
caller.
+        return c.taskStateStore.set(undefined, 1, { retentionMs: 60_000 });
+      },
+    ],
+    [
+      "delete",
+      (c: TaskClient) => {
+        // @ts-expect-error exercising the runtime guard against an untyped 
caller.
+        return c.taskStateStore.delete(undefined);
+      },
+    ],
+  ])("%s rejects a non-string key without sending a request", async (_label, 
call) => {
+    const { client: c, sent } = recordingClient();
+    await expect(call(c)).rejects.toThrow(TypeError);
+    expect(sent).toHaveLength(0);
+  });
+});
+
+describe("taskStateStore.clear", () => {
+  it("sends ClearTaskStateStore with no key and resolves on the supervisor's 
OKResponse", async () => {
+    const { client: c, sent } = recordingClient({ type: "OKResponse", ok: true 
});
+
+    await expect(c.taskStateStore.clear()).resolves.toBeUndefined();
+
+    expect(sent[0]).toEqual({ type: "ClearTaskStateStore", ti_id: FAKE_TI_ID 
});
+  });
+});
diff --git a/ts-sdk/tests/coordinator/integration.test.ts 
b/ts-sdk/tests/coordinator/integration.test.ts
index ee945c1c8aa..6ee7b02a0ee 100644
--- a/ts-sdk/tests/coordinator/integration.test.ts
+++ b/ts-sdk/tests/coordinator/integration.test.ts
@@ -759,6 +759,29 @@ describe("coordinator runtime integration", () => {
     });
   });
 
+  it("binds GetTaskStateStore's ti_id to the startup TaskInstance id, not the 
dag version id", async () => {
+    let observed: unknown = "<unset>";
+    testDag.task("state_store_client", async () => {
+      observed = await getClient().taskStateStore.get("k");
+    });
+
+    const responder: Responder = (msgType) => {
+      if (msgType === "GetTaskStateStore") {
+        return { body: { type: "TaskStateStoreResult", value: 1 } };
+      }
+      return null;
+    };
+
+    const result = await 
driveSupervisor(makeStartupDetails("state_store_client"), responder);
+
+    expect(result.firstResponse!.body).toMatchObject({ type: "SucceedTask" });
+    expect(observed).toBe(1);
+
+    const getReq = result.runtimeRequests.find((r) => r.type === 
"GetTaskStateStore")!.body;
+    expect(getReq).toMatchObject({ ti_id: "ti-1", key: "k" });
+    expect(getReq["ti_id"]).not.toBe("dag-version-1");
+  });
+
   it("returns null from getVariable when the supervisor signals NOT_FOUND", 
async () => {
     let observed: string | null = "<unset>";
     testDag.task("missing_variable", async () => {
diff --git a/ts-sdk/tests/public-api.test.ts b/ts-sdk/tests/public-api.test.ts
index b6cb9541627..4a9f91bcf12 100644
--- a/ts-sdk/tests/public-api.test.ts
+++ b/ts-sdk/tests/public-api.test.ts
@@ -24,6 +24,7 @@ import type {
   ConnectionResult,
   DagSpec,
   GetXComOpts,
+  JsonValue,
   Node,
   SetXComOpts,
   TaskClient,
@@ -38,6 +39,8 @@ import type {
   TaskOptions,
   TaskRef,
   TaskSpec,
+  TaskStateStore,
+  TaskStateStoreSetOpts,
 } from "../src/index.js";
 import * as sdk from "../src/index.js";
 import {
@@ -46,6 +49,7 @@ import {
   Dag,
   getClient,
   getContext,
+  NEVER_EXPIRE,
   SUPERVISOR_API_VERSION,
   TaskHandler,
   VariableNotFoundError,
@@ -286,6 +290,7 @@ describe("public API", () => {
     expectTypeOf<typeof sdk>().not.toHaveProperty("serveDags");
     expectTypeOf<typeof sdk>().not.toHaveProperty("DagRegistry");
     expectTypeOf(SUPERVISOR_API_VERSION).toMatchTypeOf<string>();
+    expectTypeOf(NEVER_EXPIRE).toMatchTypeOf<symbol>();
   });
 
   it("keeps the Dag authoring signatures extensible via trailing specs", () => 
{
@@ -429,11 +434,24 @@ describe("public API", () => {
       (key: string, value: string, description?: string | null) => 
Promise<void>
     >();
     expectTypeOf<TaskClient["deleteVariable"]>().toEqualTypeOf<(key: string) 
=> Promise<void>>();
+    expectTypeOf<TaskStateStoreSetOpts>().toEqualTypeOf<{
+      retentionMs?: number | typeof NEVER_EXPIRE;
+    }>();
+    
expectTypeOf<TaskClient["taskStateStore"]>().toEqualTypeOf<TaskStateStore>();
+    expectTypeOf<TaskStateStore["get"]>().toEqualTypeOf<
+      <T = unknown>(key: string) => Promise<T | null>
+    >();
+    expectTypeOf<TaskStateStore["set"]>().toEqualTypeOf<
+      (key: string, value: NonNullable<JsonValue>, opts?: 
TaskStateStoreSetOpts) => Promise<void>
+    >();
+    expectTypeOf<TaskStateStore["delete"]>().toEqualTypeOf<(key: string) => 
Promise<void>>();
+    expectTypeOf<TaskStateStore["clear"]>().toEqualTypeOf<() => 
Promise<void>>();
   });
 
   it("rejects wire-format names and non-JSON XCom values", () => {
     function acceptsGetXComOpts(_opts: GetXComOpts): void {}
     function acceptsSetXComOpts(_opts: SetXComOpts): void {}
+    function acceptsTaskStateValue(_value: NonNullable<JsonValue>): void {}
 
     acceptsGetXComOpts({
       key: "result",
@@ -523,5 +541,9 @@ describe("public API", () => {
     expectTypeOf<TaskRef>().toHaveProperty("handler");
     // @ts-expect-error XCom values must be JSON-compatible.
     acceptsSetXComOpts({ key: "result", value: new Date() });
+    // @ts-expect-error task-state-store values must be JSON-compatible.
+    acceptsTaskStateValue(new Date());
+    // @ts-expect-error task-state-store values must not be null.
+    acceptsTaskStateValue(null);
   });
 });

Reply via email to