Commit b0f6d38
committed
store: an in-flight A2A task survives a reconnect, and a reconnect is what proves it
A2A is asynchronous by design. A task spans turns, can sit interrupted waiting
on a human, and can outlive the process that started it. The engine has had a
seam for that since 1.5.6 -- `put_task`/`get_task`/`list_tasks`/
`purge_tasks_before`/`append_task_event`/`list_task_events` -- and this backend
implemented none of them, so the trait's defaults applied: accept the write,
return `Ok(())`, keep nothing. Every in-flight task was lost on every deploy,
and nothing anywhere reported it, because the return value of a write is not
evidence that anything was stored.
There are no columns here, so the SQL backends' "every field its own column"
becomes "the value IS the row": one string per task holding the whole row as
JSON, plus a SET indexing every task id so `list_tasks` -- which the contract
defines as returning EVERY row, unfiltered -- is one SMEMBERS rather than a
SCAN of the keyspace on the hot path of a boot rehydrate. The row and its index
entry land in one atomic pipeline: a row present but unindexed is a task the
listing cannot see, so the rehydrate silently loses it.
Events live in a HASH per task, field = seq, value = the event as JSON, and NOT
in a `seq`-scored ZSET. A ZSET score is an IEEE-754 double, so a seq above 2^53
would silently collide with its neighbours -- exactly the class of silent
corruption this store exists not to do -- and the contract's required UPSERT on
`(task_id, seq)` is what HSET already is, where a scored ZSET reaches it only
via a remove-then-add that is two round trips and a window. The cost is that
chain order is restored in-process on read; it is sorted NUMERICALLY, because
the field names are decimal strings and a lexicographic sort would place seq 10
before seq 2 and hand the verifier a chain that appears not to link.
The upsert is also where the task contract genuinely DIFFERS from
`append_mcp_call`'s, a few methods up this same file. That method treats a
different record at an occupied sequence as a forked log and refuses it. A task
event is specified to upsert so the engine's write-through is idempotent on
replay -- "rejecting or duplicating a replayed seq breaks the chain the engine
will verify on read". Copying this file's own fork check would have been wrong
in a way that looks right.
Retention does a full pass over the task rows rather than reading a scored
index, and that is deliberate rather than the lazy option. A ZSET scored by
`updated_at` hits the same double-precision hazard, no score can express
"terminal" so the row would have to be read anyway, and `list_tasks` is already
specified as an every-row read the rehydrate performs routinely -- so an
every-row sweep is not a new cost class, and it is exact. Terminal is a closed
named set: a state token this build has never heard of reads as not-terminal,
so a store compiled before a state existed cannot delete a task it does not
understand. A purged task takes its provenance chain with it, because
`purge_tasks_before` is the only retention method the contract gives this data.
A DIVERGENCE FROM THE SQL BACKENDS, asserted rather than assumed. Those store
these counters in a signed 64-bit column and must REFUSE a `u64` above
`i64::MAX`, because such a value cannot read back as itself. Here the row is
JSON, where a `u64` is exact across its whole range, so there is nothing to
refuse and a refusal would be inventing a limit this backend does not have. The
boundary is proven the other way round instead: `u64::MAX` goes in and comes
back out unchanged.
The test that carries this drops the store -- closing its connection -- then
connects a genuinely new one and reads the task back off the server. Run before
the methods existed, against the accept-and-keep-nothing defaults, all seven
fail, the headline being "got None back from a new connection"; that is the
behaviour this backend replaces.
One test-only fix rides along, because it would otherwise redden this branch's
CI about once a run. `purge_mcp_calls_before` and `purge_tasks_before` are
global by timestamp -- that is the contract, not a shortcut -- and this suite
deliberately shares one Valkey without wiping, isolating by key namespace. A
sweep does not name the rows it removes, so namespace isolation cannot help it:
one test's purge deleted another test's records between that test's append and
its read. It presented as "retention quietly removed nothing" and it MOVED from
one test to another as the timestamp bands were shuffled, which is how it was
diagnosed. The tests that sweep now serialise against each other; the ones that
do not sweep place their rows above every cutoff, so no sweep can reach them.1 parent 9ca7323 commit b0f6d38
2 files changed
Lines changed: 629 additions & 2 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
70 | 70 | | |
71 | 71 | | |
72 | 72 | | |
73 | | - | |
| 73 | + | |
| 74 | + | |
74 | 75 | | |
75 | 76 | | |
76 | 77 | | |
| |||
124 | 125 | | |
125 | 126 | | |
126 | 127 | | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
127 | 168 | | |
128 | 169 | | |
129 | 170 | | |
| |||
1596 | 1637 | | |
1597 | 1638 | | |
1598 | 1639 | | |
| 1640 | + | |
| 1641 | + | |
| 1642 | + | |
| 1643 | + | |
| 1644 | + | |
| 1645 | + | |
| 1646 | + | |
| 1647 | + | |
| 1648 | + | |
| 1649 | + | |
| 1650 | + | |
| 1651 | + | |
| 1652 | + | |
| 1653 | + | |
| 1654 | + | |
| 1655 | + | |
| 1656 | + | |
| 1657 | + | |
| 1658 | + | |
| 1659 | + | |
| 1660 | + | |
| 1661 | + | |
| 1662 | + | |
| 1663 | + | |
| 1664 | + | |
| 1665 | + | |
| 1666 | + | |
| 1667 | + | |
| 1668 | + | |
| 1669 | + | |
| 1670 | + | |
| 1671 | + | |
| 1672 | + | |
| 1673 | + | |
| 1674 | + | |
| 1675 | + | |
| 1676 | + | |
| 1677 | + | |
| 1678 | + | |
| 1679 | + | |
| 1680 | + | |
| 1681 | + | |
| 1682 | + | |
| 1683 | + | |
| 1684 | + | |
| 1685 | + | |
| 1686 | + | |
| 1687 | + | |
| 1688 | + | |
| 1689 | + | |
| 1690 | + | |
| 1691 | + | |
| 1692 | + | |
| 1693 | + | |
| 1694 | + | |
| 1695 | + | |
| 1696 | + | |
| 1697 | + | |
| 1698 | + | |
| 1699 | + | |
| 1700 | + | |
| 1701 | + | |
| 1702 | + | |
| 1703 | + | |
| 1704 | + | |
| 1705 | + | |
| 1706 | + | |
| 1707 | + | |
| 1708 | + | |
| 1709 | + | |
| 1710 | + | |
| 1711 | + | |
| 1712 | + | |
| 1713 | + | |
| 1714 | + | |
| 1715 | + | |
| 1716 | + | |
| 1717 | + | |
| 1718 | + | |
| 1719 | + | |
| 1720 | + | |
| 1721 | + | |
| 1722 | + | |
| 1723 | + | |
| 1724 | + | |
| 1725 | + | |
| 1726 | + | |
| 1727 | + | |
| 1728 | + | |
| 1729 | + | |
| 1730 | + | |
| 1731 | + | |
| 1732 | + | |
| 1733 | + | |
| 1734 | + | |
| 1735 | + | |
| 1736 | + | |
| 1737 | + | |
| 1738 | + | |
| 1739 | + | |
| 1740 | + | |
| 1741 | + | |
| 1742 | + | |
| 1743 | + | |
| 1744 | + | |
| 1745 | + | |
| 1746 | + | |
| 1747 | + | |
| 1748 | + | |
| 1749 | + | |
| 1750 | + | |
| 1751 | + | |
| 1752 | + | |
| 1753 | + | |
| 1754 | + | |
| 1755 | + | |
| 1756 | + | |
| 1757 | + | |
| 1758 | + | |
| 1759 | + | |
| 1760 | + | |
| 1761 | + | |
| 1762 | + | |
| 1763 | + | |
| 1764 | + | |
| 1765 | + | |
| 1766 | + | |
| 1767 | + | |
| 1768 | + | |
| 1769 | + | |
| 1770 | + | |
| 1771 | + | |
| 1772 | + | |
| 1773 | + | |
| 1774 | + | |
| 1775 | + | |
| 1776 | + | |
| 1777 | + | |
| 1778 | + | |
| 1779 | + | |
| 1780 | + | |
| 1781 | + | |
| 1782 | + | |
| 1783 | + | |
| 1784 | + | |
| 1785 | + | |
| 1786 | + | |
| 1787 | + | |
| 1788 | + | |
| 1789 | + | |
| 1790 | + | |
| 1791 | + | |
| 1792 | + | |
| 1793 | + | |
| 1794 | + | |
| 1795 | + | |
1599 | 1796 | | |
1600 | 1797 | | |
1601 | 1798 | | |
| |||
0 commit comments